diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3e552..bca1656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,50 @@ on the pattern by luck -- without that, every setting would be itself and 1/8192 in parallel, and no setting could ever be rarer than 8192. +- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked + through, so it opens on two opposite faces -- and the overworld drawing + can only show one. The sprite is a facade seen face-on under a roof + seen from above, so a SOUTH entrance is drawn (a doorway block in the + facade's last rows, which `Structures` folds up into the front face) + and a north, east or west one is drawn as nothing at all: the warp + sits on the ground cell outside, the art beside it is plain wall. + Top-down that reads fine, because the wall is never seen. In 3D you + walked into a blank slab -- 33 of them, on eleven gates. + + Each is now a real doorway, one cell of the tileset's own door art + standing on the ground of the face you walk into, hung by the SAME + rule the drawn facade hangs its own door by: the black frame stays + flush with the wall and what it seals sinks a voxel behind it, so a + side door and a front door are the same opening at the same depth, and + the jambs the recess exposes fall out of the mesher wearing the + frame's own texels. The art's outer ring is left alone -- a doorway + cell is a cell OF a facade, its border is the wall beside and above + the frame, and painting all 16x16 would stamp a one-pixel strip of + front-wall art around every door. The same flood the facade tells wall + from pane with, bounded to the cell, tells them apart here. + + **Nothing is authored but the art.** `data/voxel_heights.lua` names + one door cell per tileset and no coordinates: where the doors go is + read off the map, from the warps that land in a gate house and the + building standing against them. A warp is an entrance when it leads + into a GATE-tileset map, is not already ON a door tile (a drawn south + door would be fought over), and stands on a WALKABLE cell -- the ROM + gives several gates an unreachable twin warp on the fence or tree + beside the real opening, and a door behind a fence is a door into + nothing. What is left is the entrance, and the two cells side by side + that most gates do have come out as the double door they always were. + So no hand list to drift out of step with a map edit, and every other + building in the game is untouched: exactly the eleven gate placements + get doors. + + Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block + is the gate on four routes and the warps sit at different rows of it + on each -- so the model cache is keyed by the openings as well, and + only placements that agree share a model. It costs about 290 quads a + door (a flank quad carries one texel, so the art cannot merge into + strips the way a facade's does) and no voxels: the recess removes as + many faces as it exposes. + ## 1.8.0 ### Added diff --git a/README.md b/README.md index 359cd04..cf80fe6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Dramatic Shape Voxel Mod -Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission. - A mod for the [Pokémon Gen 1 Recompilation Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project). diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 6af0a03..2bb11c5 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -890,9 +890,8 @@ return { -- which has a PERSON drawn into the tile art -- becomes a monolith -- wearing his face. POKECENTER = { - -- the wall band stays one 16px face: striped panels (40), the high - -- windows (92-95; 94 doubles as the map's warp tile, and pins are - -- look-only), the pokeball poster (2/3/18/19), and the pillars + -- the wall band stays one 16px face: striped panels (40), the + -- pokeball poster (2/3/18/19), and the pillars -- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback -- trap and would recess into a pond lip). The healing machines' -- console face (76/77) and button panel (6/22) are ALSO wall: @@ -905,12 +904,37 @@ return { -- void rule flattens them. What is NOT wall is the machines' -- two flanks -- see `prop` below. wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, - 76, 77, 92, 93, 94, 95 }, - -- the counters, half a cell high: top band (8) with the nurse's - -- tray (10), front face (24/25, the game's counterTiles), left end - -- cap (56) and the Cable Club's light sections (90/91). 8px is - -- one clean band, so the drawn front panel stands up and the - -- counter top stays on top; at 12 they read as wall stubs + 76, 77 }, + -- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and + -- (12,0) of every Center -- a flight going DOWN, away from the room, + -- and the reason `stair_down_n` exists at all: the profile's other + -- stairs run east or west and are drawn from the SIDE, where a drawn + -- column is a step; these are drawn HEAD-ON, where a drawn row is, + -- and no rotation of the east/west reading produces that. + -- + -- The drawing is its own band table, and it lands exactly on an even + -- four-step division of the cell: 4 white rows (the near tread), a + -- black nosing, 3 grey, a nosing, 3 checker, then 4 black rows -- the + -- dark the flight leaves into, which is also what the far end wall + -- wears. Its first and last COLUMNS are the well's black side walls. + -- Drawn row = depth row throughout; the rise is the only number the + -- head-on view cannot state, and it takes the class height over the + -- four steps like every other flight here. + -- + -- Pinned as one cell (the class resolves off the top-left tile) but + -- all four ids carry it, and the scan says they cannot reach anything + -- else: 22 placements, exactly the two cells in each of the eleven + -- Centers, and the Celadon Hotel on the same id places none of them. + -- 94 is also the map's warp tile; pins are look-only, so the warp is + -- untouched and the steps stay walk-through. + stair_down_n = { 92, 93, 94, 95 }, + -- the counters, half a cell high: top band (8) and the one cell of + -- it that carries the push bell (10, lifted off as a figure below -- + -- the pin stays as the degradation path), front face (24/25, the + -- game's counterTiles), left end cap (56) and the Cable Club's light + -- sections (90/91). 8px is one clean band, so the drawn front panel + -- stands up and the counter top stays on top; at 12 they read as + -- wall stubs counter = { 8, 10, 24, 25, 56, 90, 91, -- and the lounge couch's SEAT column with the man -- sitting on it. Same half-cell box: its bottom row @@ -1035,6 +1059,74 @@ return { -- on the arm. The background corners around his head and the -- cushion wedge under his legs are the only pixels given back. figures = { + -- THE PUSH BELL on the reception counter. One tile, $0A, drawn in + -- the counter's TOP tile row at cell (3,2) -- the same cell in all + -- eleven Centers and nowhere else on this id (scan: 11 hits, all + -- tile (7,4)). Every other counter cell in the game runs 8 over + -- 24/25; this one runs 8/10 over 24/25, and 10 is 8 with the bell + -- painted into its east half. + -- + -- It could not be a class pin: a pin resolves a whole 8x8 tile, and + -- the tile is three quarters counter top. Pinned with the counter + -- (which is what it was) the bell was just ink lying on the + -- surface -- and lying on it TWICE, because the counter's one top + -- row had to cover a 16px-deep plot and the mesher repeated it (see + -- the half-cell rule in ChunkMesher: fixed, and the two stacked + -- bells were what showed it). + -- + -- So it is lifted off by mask, exactly like the Marts' till, and + -- `under` puts plain 8 back -- the counter top the artist drew for + -- every other cell of the same run, so nothing is synthesized and + -- the surface closes up seamlessly. + -- + -- Unlike the till it is NOT an extrusion of its drawing. Seven + -- pixels by six of ¾-view dome state a round object and nothing + -- else usable: every reading that turns six rows into geometry + -- invents more than it measures. So the solid is AUTHORED (see + -- TileShape's `model`) -- a 5x3 puck with its corners taken off, + -- one voxel proud of the counter, with a single button voxel at + -- its centre. `pixels` stays as the segmentation: it is what says + -- where on the tile the bell is, and the model centres on it. + -- + -- COLOUR is still not authored. Each layer names the texel its + -- faces wear, and all four come off tile 8 -- the counter's own + -- plain top, whose first rows are one flat shade each: row 0 its + -- black back edge, row 1 its white highlight, row 5 its light + -- band. So the puck's sides are the desk's own light shade, its + -- top the desk's own white, and the button's sides the desk's own + -- black, and all four follow every palette bake with it. + -- + -- It stands at the FRONT of the counter cell: a service bell is on + -- the customer's side of the desk, and this is the only object in + -- the profile whose depth its drawing does not state. `inset` 2 + -- backs it off the counter's own front lip -- flush read as balanced + -- on the edge; this is the number to move to slide it either way. + { + w = 1, + inset = 2, + tiles = { 10 }, + under = { 8 }, + model = { + { plan = { "0xxx0", + "xxxxx", + "0xxx0" }, + top = { 8, 1 }, side = { 8, 5 } }, + { plan = { "00000", + "00x00", + "00000" }, + top = { 8, 1 }, side = { 8, 0 } }, + }, + pixels = { + "........", + "........", + "...XXX..", + "..XXXXX.", + ".XXXXXXX", + ".XXXXXXX", + "..XXXXX.", + "...XXX..", + }, + }, { w = 3, tiles = { 36, 37, 57, @@ -3002,6 +3094,36 @@ return { FOREST = { 42, 43, 58, 59 }, }, + -- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH. + -- + -- A route gate is walked THROUGH, so it has an opening on two opposite + -- sides -- and the drawing can only show one of them. The overworld + -- sprite is a facade seen face-on with a roof laid over it, so a south + -- entrance is drawn (a doorway block in the facade's last rows, folded up + -- by lib/Structures.lua) and a north, east or west one is drawn as + -- NOTHING: the warp sits on the ground cell outside, the art beside it is + -- plain wall, and top-down that reads fine because you never see the + -- wall. In 3D you walk straight into a blank slab. + -- + -- So the door is put back, on the face the player walks into. This names + -- only the ART -- one 16x16 cell of the tileset's own doorway block, rows + -- north-first, the same ids `frontOnly` above lists as facade-only. + -- WHERE it goes is not authored at all: lib/Buildings.lua reads it off + -- the map, from the warps that land in a gate and the building standing + -- against them (see `sideDoors` there), because the map already states it + -- and a hand list of thirty-odd coordinates would only be a chance to get + -- one wrong. + -- + -- OVERWORLD is the whole table because every such entrance in the game + -- stands against an OVERWORLD building: the Safari Zone's north gate is a + -- gap between two fence stubs with no drawing to carve, and the Route 22 + -- league gate on ROUTE_23 puts its warps on the road THROUGH the arch + -- rather than against a wall. Both come out with no door, which is what + -- they always had. + sideDoors = { + OVERWORLD = { { 11, 12 }, { 27, 28 } }, + }, + -- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua, -- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its -- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 8694046..e2ceba2 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -40,7 +40,6 @@ local TerrainAtlas = V.require("TerrainAtlas") local VoxelScene = V.require("VoxelScene") local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") -local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") local AntiAlias = V.require("AntiAlias") local PaletteFX = require("src.render.PaletteFX") @@ -640,12 +639,10 @@ function BattleScene.render(state, arena, textures, token) local sunWas = Voxel3D.SHADOW_ALPHA Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA * DayNight.shadowScale(outdoor) - -- and the wireframe is ON for a battle whatever the V-GRID row says. The - -- arena is a staged shot rather than the world being walked through, and - -- the seams are what make it read as built rather than photographed. Forced - -- through the override so the player's own row is never written to. - local gridWas = VoxelGrid.override - VoxelGrid.override = true + -- The wireframe is whatever the V-GRID row says, exactly as it is out in + -- the world (see VoxelGrid): the arena is drawn a unit per voxel like + -- everything else, so the seams follow the one toggle and a player who + -- turned them off does not get them back for the length of a fight. local out = nil local ok, err = pcall(function() -- its own canvas slot: this renders at the window's pixel size and the @@ -845,7 +842,6 @@ function BattleScene.render(state, arena, textures, token) -- renders (the free-roam pipeline, next frame) must find the orbit back Voxel3D.camera = nil Voxel3D.SHADOW_ALPHA = sunWas - VoxelGrid.override = gridWas if not ok then -- endScene never ran, so the canvas is still bound and the shader still -- set; put the frame back the way it was found before rethrowing diff --git a/lib/Buildings.lua b/lib/Buildings.lua index f11d1ca..d90971f 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -136,6 +136,13 @@ end local models = {} -- ":" -> prebuilt local quads local frontSets = {} -- tileset id -> { [tile] = true } or false +-- The tileset's side-door art: the 2x2 tile grid of one doorway cell +-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none. +function Buildings.sideDoorCell(tilesetId) + local s = profile() + return s and s.sideDoors and s.sideDoors[tilesetId] or nil +end + -- The tileset's front-only tiles as a set (data/voxel_heights.lua -- `frontOnly`): the doorways, shop signs and painted lettering that belong -- on a facade and on no other face of the same building. nil when the @@ -252,6 +259,82 @@ end -- topRows (placement is still by `tiles` alone); they exist so the MODEL -- is built from the complete drawing and the tower rises to its real -- height instead of folding as two half-buildings. +-- Composite one doorway cell PAST the end of the sprite, at indices +-- W*H .. W*H+255, and hand back its base. The side-door pass paints with +-- sprite indices like everything else -- `emit` resolves a voxel's colour +-- through sp.ax/sp.ay and knows nothing about where the index came from -- +-- so the door only has to BE in the sprite arrays to travel the rest of +-- the pipeline untouched. Appending rather than drawing into the grid is +-- the point: the drawing itself must not change, or the silhouette flood, +-- the taper and every measured band would be read off art the tileset +-- never placed here. +-- +-- Nothing else walks past W*H (measure's shadeTexel scan and the pane +-- flood both stop there), so the block is invisible to measurement and +-- visible only to the code that asks for it by index. +-- +-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway +-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer +-- ring is the wall beside and above the frame, and its last row is the +-- black threshold the building stands on with the door's own step cut into +-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of +-- front-wall art around every door. +-- +-- The front facade tells the ring from the door by flooding: the wall +-- around the door is one region with the whole facade, far too big to be a +-- pane, and only what the black frame SEALS sinks. The same test, bounded +-- to the block: flood the left, right and top edges through their own +-- shade class, and what the flood reaches is context -- left unpainted, so +-- the flank keeps the texel it already had. Not the bottom edge, because +-- the bottom edge is the ground: the step under the door is sealed there +-- on the drawn facade too, and it recesses with the rest of the doorway. +-- +-- What is painted then splits the way a facade's does: black is frame and +-- stays flush with the wall, everything it seals sinks a voxel behind it. +local function readDoor(sp, data, perRow, cell) + local base = sp.W * sp.H + local black = {} + for dy = 0, 15 do + local row = cell[math.floor(dy / 8) + 1] + for dx = 0, 15 do + local tile = row[math.floor(dx / 8) + 1] + local px = (tile % perRow) * 8 + dx % 8 + local py = math.floor(tile / perRow) * 8 + dy % 8 + local k = dy * 16 + dx + local i = base + k + sp.ax[i], sp.ay[i] = px, py + local r, g, b, a = data:getPixel(px, py) + sp.col[i] = shadeOf(r, g, b, a) + sp.inside[i] = true + black[k] = sp.col[i] == BLACK + end + end + + local context, stack = {}, {} + local function seed(dx, dy, cls) + if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end + local k = dy * 16 + dx + if context[k] or black[k] ~= cls then return end + context[k] = true + stack[#stack + 1] = k + end + for dy = 0, 15 do + seed(0, dy, black[dy * 16]) + seed(15, dy, black[dy * 16 + 15]) + end + for dx = 0, 15 do seed(dx, 0, black[dx]) end + while #stack > 0 do + local k = table.remove(stack) + local dx, dy, cls = k % 16, math.floor(k / 16), black[k] + seed(dx + 1, dy, cls) + seed(dx - 1, dy, cls) + seed(dx, dy + 1, cls) + seed(dx, dy - 1, cls) + end + + sp.door = { base = base, black = black, context = context } +end + local function read(t, data, perRow, frontOnly) local tiles = t.tiles if t.topRows then @@ -1087,11 +1170,113 @@ local function deskSetModel(sp, pr, t) W = W, ytop = ytop, zmin = 0, zmax = D - 1 } end +-- ------- the doorway a gate house is entered by from a side the drawing +-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt` +-- below for how the placements are found). +-- +-- One cell of the tileset's own doorway art, standing on the ground of the +-- face the player walks into, and hung by the SAME rule the drawn facade +-- hangs its own door by: the art's black frame stays flush with the wall +-- and everything it seals sinks a voxel behind it (`measure`'s pane pass, +-- applied here by hand because the art is not in the drawing to be flooded +-- with it). So a side door and a front door are the same depth of the same +-- opening, and the jamb faces the recess exposes come out of the mesher for +-- free, wearing the frame's own texels. +-- +-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher +-- picks it per voxel, so which art column lands at which world coordinate +-- is decided HERE and nowhere else -- and a face is read from outside, so +-- the art's own left-to-right runs with the viewer's, not with the world's: +-- facing east at a west wall, south is to your right (+z); facing west at +-- an east wall, north is (-z); facing south at a north wall, west is (-x). +-- Two of the three are mirrored against the axis, which is the same reason +-- `backMap` exists -- a wall seen from behind IS the drawing mirrored. +local DOOR = 16 + +local function sideDoors(at, sp, doors, W) + if not (doors and #doors > 0 and sp.door) then return at end + local art = sp.door + + -- The face's OUTER surface, walked in from the box edge until the wall + -- answers. A drawing inset from its own grid (B03's outer columns are + -- terrain, not building) stands its flank a column or two in, and a door + -- pinned to the box edge would hang in the air beside it. + local function faceX(from, step, off) + for k = 0, W - 1 do + local x = from + step * k + for y = 0, DOOR - 1 do + for z = off, off + DOOR - 1 do + if at(x, y, z) then return x end + end + end + end + return nil + end + + local list = {} + for _, d in ipairs(doors) do + local face = 0 -- north: the facade's own z origin + if d.side == "w" then face = faceX(0, 1, d.at) + elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end + if face then + list[#list + 1] = { side = d.side, off = d.at, face = face } + end + end + if #list == 0 then return at end + + -- art column at (x, z) for door `e`, or nil when the voxel is not in it + local function column(e, x, z) + if e.side == "n" then + if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then + return e.off + DOOR - 1 - x, z == 0 + end + elseif z >= e.off and z < e.off + DOOR then + if e.side == "w" then + if x == e.face then return z - e.off, true end + if x == e.face + 1 then return z - e.off, false end + else + if x == e.face then return e.off + DOOR - 1 - z, true end + if x == e.face - 1 then return e.off + DOOR - 1 - z, false end + end + end + return nil + end + + return function(x, y, z) + local v = at(x, y, z) + -- no wall here is the end of it: a door is hung ON the building, and + -- nothing about it may add geometry the drawing does not stand up + if v == nil or y >= DOOR then return v end + for _, e in ipairs(list) do + local c, outer = column(e, x, z) + if c then + -- art row 0 is the door's head, so the ground row is its last + local k = (DOOR - 1 - y) * DOOR + c + -- the art's own ring: wall, not door. The flank keeps its texel. + if art.context[k] then return v end + if art.black[k] then + -- the frame, flush with the wall; behind it the wall stands on + if outer then return art.base + k end + return v + end + -- and what the frame seals sinks: the face voxel goes, the one + -- behind it wears the art. (Written long: `outer and nil or i` + -- returns i for BOTH, nil being false to `and`.) + if outer then return nil end + return art.base + k + end + end + return v + end +end + -- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite -- pixel that voxel wears, or nil. Build ORDER is expressed as lookup -- order -- roof first, so it overwrites the walls it intersects, and walls --- are trimmed to its underside so nothing pokes through the surface. -local function model(sp, pr, t) +-- are trimmed to its underside so nothing pokes through the surface. A +-- gate's side doors are hung on the finished lookup, last of all, because +-- they answer to the FACE rather than to any band of the drawing. +local function model(sp, pr, t, doors) if t.parts then return deskSetModel(sp, pr, t) end local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows @@ -1209,6 +1394,8 @@ local function model(sp, pr, t) return pr.interior[i] end + at = sideDoors(at, sp, doors, W) + return { at = at, W = W, ytop = ytop, zmin = ledge0 and -2 or 0, zmax = math.max(rz1, ledge0 and (D + 1) or 0) } @@ -1420,6 +1607,82 @@ local function matches(S, t, tx, ty) return true end +-- ------- which of a placement's faces a gate is entered by +-- +-- Read off the MAP, not authored: a gate entrance is a warp that lands in a +-- gate house, and the face is whichever one of this placement the warp cell +-- stands against. Thirty-odd doors fall out of two lines of geometry, and +-- none of them can drift out of step with a map edit the way a hand list +-- would. Three tests, each of them load-bearing: +-- +-- the destination is a GATE tileset -- what makes a building a gate house +-- rather than a house with a back door. GATE and FOREST_GATE both, so +-- the Viridian Forest pair count; the Safari rest houses are on GATE +-- too and are excluded by the next test, their warps being drawn doors +-- already. +-- the cell is not already a door tile -- a south entrance IS drawn, as a +-- doorway block in the facade, and lib/Structures.lua folds it up into +-- the front face. Adding a second one there would fight it. +-- the cell is WALKABLE -- the ROM gives an unreachable twin warp to +-- several gates (a fence cell beside the real opening on Route 7 west +-- and Route 16 east, a tree beside Route 6's), and a door on the wall +-- behind a fence is a door into nothing. The reachable cells are the +-- entrance, and two of them side by side are the gate's real two-cell +-- opening, which comes out as the double door it always was. +-- +-- The south face is skipped whether or not it is drawn: it is the one face +-- the drawing states in full, so anything it needs it already has. +local function sideDoorsAt(map, tileset, tx, ty, bw, bh) + -- through the module, NOT a global: `Game` is a local everywhere in the + -- engine (`local Game = require("src.core.Game")` in a dozen files) and + -- reading `_G.Game` came back nil every time -- which fails silently and + -- exactly like the feature being off, because a nil map table is also + -- what a headless build legitimately has. + local ok, G = pcall(require, "src.core.Game") + local defs = ok and G and G.data and G.data.maps + local warps = map.def and map.def.warps + if not (defs and warps and warps[1]) then return nil end + if not Buildings.sideDoorCell(tileset.id) then return nil end + + local out = nil + for _, w in ipairs(warps) do + local dest = defs[w.destMap] + if dest and dest.tileset and dest.tileset:find("GATE", 1, true) + and map:isWalkableCell(w.x, w.y) + and not map:isDoorTileCell(w.x, w.y) then + -- the cell in the model's own pixels: a cell is two tiles, a tile + -- eight pixels, and the placement's origin is (tx, ty) in tiles + local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8 + local side = nil + if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n" + elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w" + elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end + if side then + out = out or {} + out[#out + 1] = { side = side, at = side == "n" and lx or lz } + end + end + end + if out then + table.sort(out, function(a, b) + if a.side ~= b.side then return a.side < b.side end + return a.at < b.at + end) + end + return out +end + +-- The model cache key's door half. A template's doors belong to the +-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps +-- sit at different rows of it on each -- so two placements of one drawing +-- are two models, and only placements that agree share one. +local function doorKey(doors) + if not doors then return "" end + local parts = {} + for i, d in ipairs(doors) do parts[i] = d.side .. d.at end + return "#" .. table.concat(parts, ",") +end + -- Find every placement of every template for this map's tileset, build one -- model per template, and stamp it. Returns nothing; the quads land in -- S.objectQuads and the tiles are claimed so the volume path never boxes a @@ -1440,6 +1703,9 @@ function Buildings.build(S, map, data, perRow) if type(t.tiles) == "table" and #t.tiles > 0 then local bh, bw = #t.tiles, #t.tiles[1] local first = t.tiles[1][1] + -- the model this placement stamps. Not hoisted out of the loops any + -- more: a template's doors belong to the placement, so two hits of + -- one drawing on the same map can be two models (see doorKey). local built = nil for ty = 0, th - bh do Budget.tick() @@ -1465,8 +1731,13 @@ function Buildings.build(S, map, data, perRow) end end if free and matches(S, t, tx, ty) then - if not built then - local key = tileset.id .. ":" .. index + do + -- keyed per PLACEMENT once a gate's doors are in play (see + -- doorKey): the drawing is shared, the openings are not + local doors = not t.claimOnly + and sideDoorsAt(map, tileset, tx, ty, bw, bh) + or nil + local key = tileset.id .. ":" .. index .. doorKey(doors) if not models[key] then if t.claimOnly then -- claim the cells, stamp nothing: the drawing here is @@ -1478,8 +1749,13 @@ function Buildings.build(S, map, data, perRow) else local sp = read(t, data, perRow, Buildings.frontOnly(tileset.id)) + if doors then + readDoor(sp, data, perRow, + Buildings.sideDoorCell(tileset.id)) + end local pr = measure(sp, t) - models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH) + models[key] = emit(model(sp, pr, t, doors), sp, + atlasW, atlasH) end end built = models[key] diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 6344b90..4167656 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -65,6 +65,26 @@ end local ChunkMesher = {} +-- Which drawn row a FLAT-topped volume's top face wears at depth `ty`. +-- +-- A structure is usually deeper than the art that draws it, so the rows +-- cycle and the drawing repeats down the top. That is right for art which +-- genuinely repeats -- the Safari Zone's fence alternates two tiles the +-- whole way down -- and wrong for a RIM over a uniform body: a cliff +-- mound's first row is its top edge, and cycling lays that edge again +-- every second tile, striping a plateau with rims it should not have. +-- +-- Where Structures found the body uniform, the rim is laid once at the +-- north edge and the body held after it. Everything else cycles as before. +function ChunkMesher.flatTopRow(run, ty) + local m = math.min(2, run.extent) + local d = ty - run.north + if run.topUniform then + return run.north + math.min(d, m - 1) + end + return run.north + (d % m) +end + -- Ring of border blocks meshed around the body, matching the width -- TileRenderer draws so the two modes end at the same place. local RING = 3 @@ -403,8 +423,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- `to` routes the quad somewhere other than the main sink -- the water -- surface is the only caller that ever does (see runGeometry's header). - local function topQuad(x0, z0, h, tile, shade, to) - local u0, u1, v0, v1 = uvRect(tile, 0, 8) + -- `vTop`/`vBot` crop the art to a row range of the tile, which only the + -- half-cell furniture rule below ever asks for: a top band that has to + -- cover more depth than it was drawn with hands each 8px cell its own + -- slice of the band instead of the whole of it. + local function topQuad(x0, z0, h, tile, shade, to, vTop, vBot) + local u0, u1, v0, v1 = uvRect(tile, vTop or 0, vBot or 8) ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 }, { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, @@ -561,11 +585,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) { x0 + 8, neY, z0 }, { x0, nwY, z0 } }, { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95) elseif run then - local m = math.min(2, run.extent) - local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m)) + local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty)) topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE) else local topTile = tile + local vTop, vBot = nil, nil if s.art == "upright" and s.authored then -- Top art for a pinned box. A furniture drawing is top-view -- rows over floor(h/8) face-on rows the fold stands upright; @@ -592,6 +616,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) end end local row = math.min(ty, front - math.floor(h / 8)) + -- HALF-CELL FURNITURE, one cell of plot: the drawing gives ONE + -- tile row of top view (the counter's surface) over one that + -- folds up as the face (its front panel), and the plot under it + -- is 16px deep. Repeating the top row over both depth rows -- + -- what `row` above resolves to, since the face row has no top + -- art of its own to wear -- draws the surface TWICE: the + -- Centers' counters ran a black back edge and its white + -- highlight down the middle of every counter, and the push bell + -- drawn on one of them came out as two bells stacked front to + -- back. The band is foreshortened, not tiled, so each depth row + -- takes HALF of it and the one drawing covers the whole top. + -- + -- Deliberately narrow: only a run that is exactly one cell deep + -- with exactly one top row. A deeper run states its own depth + -- 1:1 already (the lounge couch is four tile rows over two + -- cells, and its cushions must stay cushion-sized), and only the + -- last of its rows repeats -- which is the drawing tiling, not + -- a surface drawn once and stretched. + local face = math.floor(h / 8) + if front - face - north == 0 and front - north == 1 then + local k = ty - north + row = north + vTop, vBot = k * 4, k * 4 + 4 + end if row < north then -- the whole run folded onto the face: top with the drawn -- row just above it when that row is furniture too (a @@ -610,7 +658,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- on the pond. topQuad(x0, z0, h, topTile, s.art == "upright" and VOLUME_TOP_SHADE or 1, - (s.class == "water") and waterPush or nil) + (s.class == "water") and waterPush or nil, vTop, vBot) end -- sides: 8px bands wherever the neighbour is lower. Band k spans diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 447d45a..7129d96 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1101,43 +1101,20 @@ function OverworldBattle.sideTexture(battle, side) for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end texturing = side - -- A SHINY on this side, tinted here rather than in ShinyUI's flat-path - -- wrap. This is the one place a pic is rendered for ONE side at a time, - -- so it is the only place the two sides can be tinted differently -- a - -- shiny facing an ordinary mon gets its own colour and leaves the other - -- alone, which the engine's both-sides-at-once pic layer cannot do. - local shinyTint = nil - do - -- NOT when this side is showing a PERSON. Both sides can be holding a - -- trainer pic rather than a Pokemon -- the foe's portrait before the - -- send-out, and the player's own back until "Go!" -- and a shiny is a - -- fact about a Pokemon, not about its owner. Tinting through it turned - -- the player's trainer sprite a different colour for the whole intro, - -- which is what a shiny Pokemon in the party looks like if you do not - -- ask this question. The two tests are the same ones sideTexture already - -- uses to label the finished texture, asked here instead of after. - local person = (side == "enemy" - and battle.showEnemyTrainer and battle.trainerPic) - or (side == "player" - and battle.showPlayerBack and battle.playerBackPic) - if not person then - local battler = (side == "player") and battle.player or battle.enemy - local g2 = game() - shinyTint = battler and V.require("ShinyUI") - .tintFor(battler.mon, g2 and g2.data) or nil - end - end - + -- ------- no shiny tint here any more + -- + -- This used to bracket the draw below with that side's shiny tint, on the + -- grounds that rendering one side at a time is the only place the two can + -- be coloured differently. True, and no longer needed: the PIC itself is + -- now built from a shiny palette (lib/ShinyPics.lua), which is per-mon + -- rather than per-side and gets the colour right instead of approximating + -- it with a multiply. Tinting on top of that would apply the shift twice. local ok, err = pcall(function() g.setCanvas(canvas) g.clear(0, 0, 0, 0) g.setBlendMode("alpha") g.setColor(1, 1, 1, 1) - if shinyTint then - V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0) - else - innerPics(battle, 0, 0, 0) - end + innerPics(battle, 0, 0, 0) end) texturing = nil @@ -1288,6 +1265,22 @@ function OverworldBattle.install() return TEX_AX - w * scale / 2, TEX_AY - h * scale, s end + -- ------- the shiny arrival sparkle, on every rung this file draws + -- + -- Called from BOTH branches below, because both are a complete battle + -- frame: the `not shot` branch is the engine's own screen (3D-BTL OFF, and + -- any battle the mod does not stage), and the other is the staged shot. + -- + -- It lives here rather than on a hook or a monkeypatch of its own because + -- this override IS the battle's draw -- every rung, every frame. The two + -- other seams were tried and measured at zero calls: BattleState:update is + -- never reached (the battle is not the top of the stack during its own + -- intro), and the engine's `battle.overlay` hook is only reached through + -- the tail of the engine's draw. See lib/ShinyFlash.lua. + local function shinyFlash(battle) + pcall(function() V.require("ShinyFlash").render(battle) end) + end + local innerDraw = BattleState.draw function BattleState:draw() local shot = OverworldBattle.shot() @@ -1298,7 +1291,9 @@ function OverworldBattle.install() -- that loses its arena mid-fight goes back to white voids self.letterboxWhite = nil self.dramaticShapeShot = nil - return innerDraw(self) + local out = innerDraw(self) + shinyFlash(self) + return out end self.dramaticShapeShot = shot -- The world reaches the screen through the seam a render pipeline's @@ -1325,6 +1320,7 @@ function OverworldBattle.install() -- own HUD drew in, so it letterboxes and chunks identically local cap = BattleScene.capture if cap and cap.drawGB then pcall(cap.drawGB, self) end + shinyFlash(self) end -- The mons are geometry standing on the map now, drawn in the 3D pass diff --git a/lib/ShinyFlash.lua b/lib/ShinyFlash.lua new file mode 100644 index 0000000..5b45b21 --- /dev/null +++ b/lib/ShinyFlash.lua @@ -0,0 +1,323 @@ +-- The arrival sparkle, on the FLAT battle screen. +-- +-- ------- why ShinyFx could not be reused +-- +-- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it +-- is about the 3D arena: it is armed from Stadium.update on the frame a +-- side's model changes, it is sized from the model's own world height and +-- radius, and it draws additive quads into the voxel scene through +-- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no +-- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards +-- rather than building a model to measure. +-- +-- So the effect was Stadium-only, and had been since it was written: ShinyFx +-- .arm is called from exactly one file. On every other rung a shiny simply +-- appeared, with no announcement. This is the announcement, in the one +-- coordinate space those rungs share -- the Game Boy's own 160x144 grid, +-- where the pic itself is drawn. +-- +-- ------- the two slots +-- +-- Both are the engine's, and neither moves: the enemy's front pic lives in +-- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's +-- back pic stands at x=8 with its feet on the text box at y=96, two-times +-- scaled, so it fills y 32..96. The burst springs from a point inside each, +-- a little above centre, which is roughly where a Pokemon's chest is in art +-- drawn to fill its box. +-- +-- Deliberately NOT measured off the drawn image. resolveBattleScale can +-- rescale a pic per species, the send-out grow animates the scale from zero, +-- and following either would make the burst jump around during exactly the +-- moment it is playing. The slot is fixed; the sparkle uses the slot. +-- +-- ------- black AND white, both +-- +-- Each spark is drawn twice: a wider near-black cross, then a white one +-- inside it. One colour alone would be invisible half the time -- the battle +-- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D +-- rungs composite the same pic over a sky or a map, where a black one does. +-- The pair reads on both, and costs ten extra rectangles. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Shiny = V.require("Shiny") + +local ShinyFlash = {} + +ShinyFlash.LIFE = 0.75 -- seconds, matching ShinyFx +ShinyFlash.SPARKS = 9 + +-- The two slots, in GB pixels: where the burst starts and how far it travels. +ShinyFlash.SLOTS = { + enemy = { x = 124, y = 24, rx = 34, ry = 26 }, + player = { x = 40, y = 62, rx = 34, ry = 30 }, +} + +-- Where each spark sits on the ring, as a fraction of a turn. Spread by hand +-- rather than randomly: nine sparks on an even ring reads as a ring, and nine +-- random ones read as a mess at this size. The half-step offset on alternate +-- sparks keeps it from looking like a clock face. +local ANGLES = {} +for i = 1, ShinyFlash.SPARKS do + ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0) + / ShinyFlash.SPARKS +end + +-- ------- why the clock is the WALL clock +-- +-- Everything here happens on the DRAW side (see install), and a draw is +-- handed no dt. Rather than accumulate one nobody offers, a burst records +-- the time it started and its age is read back off love.timer. +-- +-- That also makes it immune to being asked to draw more than once in a +-- frame, which the wide layout does -- once per side -- and which a +-- per-call dt accumulator would age at double speed. +local function now() + return (love.timer and love.timer.getTime and love.timer.getTime()) or 0 +end + +-- live bursts: side -> the time it started +local live = {} + +-- what each side's pic was showing last frame, so an arrival is an EDGE +local showing = {} + +-- for the tests and the shot drivers, the way ShinyFx.debug is +ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0, + armed = 0, draws = 0, sparks = 0, err = "" } + +function ShinyFlash.arm(side) + live[side] = now() + ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1 +end + +function ShinyFlash.clear(side) + live[side] = nil +end + +function ShinyFlash.reset() + live, showing = {}, {} +end + +-- How far through its life this side's burst is, 0..1, or nil when there +-- isn't one (or it has finished, which retires it on the way past). +function ShinyFlash.age(side) + local started = live[side] + if not started then return nil end + local u = (now() - started) / ShinyFlash.LIFE + if u >= 1 then + live[side] = nil + return nil + end + return u +end + +function ShinyFlash.active(side) + return ShinyFlash.age(side) ~= nil +end + +-- ------- is a Pokemon's own pic on screen for this side +-- +-- The conditions are the engine's, read off drawPicsLayer rather than +-- guessed: a side showing a TRAINER is showing a person and not a Pokemon, +-- and the send-out, the faint fade and the safari/demo cases each have their +-- own reason for the slot to be empty. +-- +-- Returns the mon whose pic is up, or nil. +function ShinyFlash.occupant(battle, side) + if type(battle) ~= "table" then return nil end + if side == "enemy" then + if battle.showEnemyTrainer and battle.trainerPic then return nil end + local b = battle.enemy + if not (b and b.sprite) then return nil end + if battle.enemyHidden or battle.enemySendingOut then return nil end + if battle.fxHidden and battle:fxHidden(b) then return nil end + return b.mon + end + if battle.showPlayerBack and battle.playerBackPic then return nil end + if battle.safari or battle.demo then return nil end + local b = battle.player + if not (b and b.sprite) then return nil end + if battle.sendingOut then return nil end + if battle.fxHidden and battle:fxHidden(b) then return nil end + return b.mon +end + +-- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a +-- switch and a wild foe's first appearance alike, which is the same edge +-- ShinyFx picks for the models. +function ShinyFlash.follow(battle) + ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1 + for _, side in ipairs({ "enemy", "player" }) do + local mon = ShinyFlash.occupant(battle, side) + if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end + if mon ~= showing[side] then + showing[side] = mon + if mon and Shiny.isShiny(mon) then + ShinyFlash.arm(side) + else + ShinyFlash.clear(side) + end + end + end +end + +-- ------- drawing +-- +-- Whole pixels. The screen this lands on is 160x144 and everything else in +-- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing +-- on a hard-edged frame. +local function spark(px, py, arm) + local g = love.graphics + px, py = math.floor(px + 0.5), math.floor(py + 0.5) + -- the dark cross first, one pixel proud of the light one on every side + g.setColor(0, 0, 0, 1) + g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3) + g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3) + g.setColor(1, 1, 1, 1) + g.rectangle("fill", px - arm, py, arm * 2 + 1, 1) + g.rectangle("fill", px, py - arm, 1, arm * 2 + 1) +end + +-- One side's burst, if it has one. +function ShinyFlash.draw(side, sx, sy) + local u = ShinyFlash.age(side) + if not u then return end + local slot = ShinyFlash.SLOTS[side] + if not slot then return end + local g = love.graphics + local r, gg, b, a = g.getColor() + + -- Out and fading. The ring eases OUT rather than travelling at a constant + -- speed -- fast off the mark, slow at the edge -- because a burst that + -- decelerates reads as thrown and one that does not reads as a wipe. + local ease = 1 - (1 - u) * (1 - u) + local fade = 1 - u + g.setColor(1, 1, 1, 1) + ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1 + + for i = 1, ShinyFlash.SPARKS do + -- every third spark is held back a little, so the ring has some depth + -- rather than nine points on one circle + local lag = (i % 3 == 0) and 0.78 or 1 + local ang = ANGLES[i] * math.pi * 2 + local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag + local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag + -- arms shrink as the spark fades, so it goes out rather than vanishing + local arm = 1 + math.floor(fade * 2.5) + spark(px, py, arm) + ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1 + end + + g.setColor(r, gg, b, a) +end + +-- ------- BEHIND the Pokemon, not over it +-- +-- The burst springs from inside the mon and flies outward, so the frames that +-- matter most are the ones where the ring is still small and sitting ON the +-- body. Drawn from the overlay hook -- the end of the battle draw -- every one +-- of those lands in FRONT of the pic, and the sparkle reads as stuck to the +-- glass rather than as coming from the Pokemon. +-- +-- So it is drawn from the PICS LAYER instead, before the engine's own pics go +-- down. That is the only place in the frame that is behind the mon and in +-- front of the field. +-- +-- The overlay hook stays, and is still the only seam the 3D rungs have: +-- OverworldBattle captured drawPicsLayer at install time and its battle draw +-- calls the captured copy, so the wrap below never runs there. Whichever seam +-- fires first draws; the other one sees the side already spent and leaves it +-- alone. `spent` is cleared by the overlay, which is the one call guaranteed +-- to happen exactly once per battle draw. +local spent = {} + +-- One side, unless it has already been drawn this frame. +local function once(side, sx, sy) + if spent[side] then return end + spent[side] = true + ShinyFlash.draw(side, sx, sy) +end + +-- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout +-- drawing one side per call, and is honoured so the burst lands in the same +-- pass its Pokemon does. +-- +-- Skipped while the layer is SLIDING (the intro walks the whole battle in +-- from the side): the slot this draws to is fixed, so a burst during the +-- slide would sit still while the mon travelled past it. Nothing is lost -- +-- the arrival edge that arms it is after the slide is over. +function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide) + ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1 + ShinyFlash.follow(battle) + if (slide or 0) ~= 0 then return end + if onlySide ~= "player" then once("enemy", sx, sy) end + if onlySide ~= "enemy" then once("player", sx, sy) end +end + +-- Follow the occupants and draw whatever the pics layer did not, in one call. +function ShinyFlash.render(battle) + ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1 + ShinyFlash.follow(battle) + once("enemy", 0, 0) + once("player", 0, 0) + spent = {} -- one battle draw ends here; the next is new +end + +-- ------- install +-- +-- Through the engine's own `battle.overlay` hook, whose comment at the call +-- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires +-- at the very end of BattleState:draw, in the Game Boy's own 160x144 space, +-- with the battle as its argument -- which is all this needs. +-- +-- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth +-- recording so it is not tried again: BattleState:update never fires during +-- the intro, because the battle is not the top of the stack there and +-- StateStack:update only calls the top. Measured -- installed, confirmed live +-- on the class, zero calls -- rather than reasoned about. +-- +-- The hook has no shake offset to give, and does not need one: it is called +-- after the screen-shake translate has been popped, so nominal coordinates +-- are the right ones. +-- +-- ------- and the second seam, for depth +-- +-- The overlay alone draws the burst OVER the Pokemon. The pics layer is +-- wrapped as well so it can go down BEHIND it (see renderBehind), on every +-- rung where the engine's own method is the one called. On the 3D rungs it is +-- not -- OverworldBattle captured drawPicsLayer at install time and calls the +-- captured copy -- and there the overlay is still the seam, which is why both +-- are installed rather than one replacing the other. +function ShinyFlash.install() + local mod = V.mod + if not (mod and mod.hooks and mod.hooks.wrap) then return false end + if ShinyFlash.installed then return true end + mod.hooks:wrap("battle.overlay", function(next, battle) + local out = next(battle) + local ok, err = pcall(ShinyFlash.render, battle) + if not ok then ShinyFlash.debug.err = tostring(err) end + return out + end) + + local okBS, BattleState = pcall(require, "src.battle.BattleState") + if okBS and type(BattleState) == "table" + and type(BattleState.drawPicsLayer) == "function" + and not BattleState.dramaticShapeShinyFlash then + local inner = BattleState.drawPicsLayer + function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...) + ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1 + local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy, + onlySide) + if not ok then ShinyFlash.debug.err = tostring(err) end + return inner(self, slide, sx, sy, onlySide, ...) + end + BattleState.dramaticShapeShinyFlash = true + end + + ShinyFlash.installed = true + return true +end + +return ShinyFlash diff --git a/lib/ShinyPalette.lua b/lib/ShinyPalette.lua index a7747c1..8614f04 100644 --- a/lib/ShinyPalette.lua +++ b/lib/ShinyPalette.lua @@ -368,17 +368,128 @@ end -- So: slide species use the slide, which is defined on all colours. Table -- species fall back to their tint multiplier, which IS derived from the -- table and does carry its direction. +-- ------- reading a SLIDE back out of a lookup table +-- +-- A multiply was the first answer here and it is not good enough. Gyarados is +-- the whole argument: its shiny is BLUE TURNING RED, and no multiply reaches +-- red from blue -- it can only darken what is already there, so the most +-- dramatic shiny in the game came out a dull mauve. That is the same ceiling +-- the flat tint hit (see lib/ShinyPics.lua), reached from the other side. +-- +-- But the table is not just a direction, it is the ANSWER: 1857 exact +-- (normal -> shiny) pairs lifted from Stadium's own alternate textures. Read +-- as HSL, each pair is a hue rotation, a saturation scale and a lightness +-- step -- which is precisely the shape of a slide. So the five table species +-- get a slide MEASURED from their own table rather than declared, and the one +-- transform serves all 151. +-- +-- Averaged over the pairs because a real alternate texture is not a perfect +-- slide -- that is why it is a texture -- but it is close enough to one that +-- the mean carries the change a player actually sees. +-- +-- hue circularly (sum the unit vectors), or opposite rotations +-- would cancel to "no change" +-- saturation as GIMP's k, s2 = s1 * (1 + k), skipping near-grey pairs +-- where the ratio is noise +-- lightness as GIMP's two-sided k, matching shiftLight +local slideCache = {} + +local function slideFromLut(lut) + local sx, sy, hueN = 0, 0, 0 + local sk, sn, lk, ln = 0, 0, 0, 0 + for key, val in pairs(lut) do + local r1 = floor(key / 65536) % 256 + local g1 = floor(key / 256) % 256 + local b1 = key % 256 + local r2 = floor(val / 65536) % 256 + local g2 = floor(val / 256) % 256 + local b2 = val % 256 + local h1, s1, l1 = rgbToHsl(r1, g1, b1) + local h2, s2, l2 = rgbToHsl(r2, g2, b2) + -- an achromatic end has no hue, so the pair says nothing about rotation + if s1 > 0.08 and s2 > 0.08 then + -- DEGREES, both of them: rgbToHsl returns h*60 and hslToRgb takes + -- `h % 360`, so the declared slides are in degrees too (-136 for + -- Charizard) and a measured one has to come out in the same unit. It + -- did not at first, and a rotation of 0.13 TURNS read as 0.13 degrees: + -- Gyarados stayed blue and the whole point of measuring was lost. + local d = math.rad(h2 - h1) + sx, sy = sx + math.cos(d), sy + math.sin(d) + hueN = hueN + 1 + sk, sn = sk + (s2 / s1 - 1), sn + 1 + end + if l1 > 0.02 and l1 < 0.98 then + lk = lk + (l2 < l1 and (l2 / l1 - 1) or ((l2 - l1) / (1 - l1))) + ln = ln + 1 + end + end + local dh = 0 + if hueN > 0 and (sx * sx + sy * sy) > 1e-9 then + dh = math.deg(math.atan2(sy, sx)) + end + -- back into the -8..+8 STEPS the slide fields are in, so the value that + -- comes out of here is the same kind of number as the 146 declared ones + return { + h = dh, + s = sn > 0 and (sk / sn) / 0.125 or 0, + l = ln > 0 and (lk / ln) / 0.125 or 0, + } +end + +-- A transform for PALETTE colours rather than texture texels. +-- +-- The two are not the same job. A lookup table answers only the colours that +-- are IN it -- the ones its model is painted with -- and the engine's palettes +-- are a different set entirely (BLUEMON's blue is not any blue on the +-- Gyarados model), so the table asked to shift a palette returns it unchanged +-- and the most dramatic shiny in the game comes out identical. +-- +-- So: slide species use their declared slide, and table species use one +-- measured out of their table by slideFromLut above. Both end up in the same +-- HSL transform, which is the only kind that can rotate a hue. +-- ------- and why the LIGHTNESS step is damped on a palette +-- +-- A slide's l is authored against a TEXTURE: thousands of texels spread +-- across the middle of the range, where "six steps darker" reads as a shadow +-- falling over the animal. A Game Boy palette is not that. It is a four-shade +-- RAMP from paper to ink, and only the middle two shades are the Pokemon -- +-- both already dark relative to the white they sit on, and both needing to +-- stay clear of the fixed ink below them. +-- +-- Applied whole, Golbat's -6 took its two shades to 27,42,37 and 34,58,52: +-- correct green, and a green nobody can see against a 25,16,16 outline. Half +-- the step keeps the direction and keeps the pic readable, which is the trade +-- the ramp forces. Hue and saturation are untouched -- they are what makes a +-- shiny recognisable as one, and neither collides with the paper or the ink. +ShinyPalette.PALETTE_LIGHT_DAMP = 0.5 + function ShinyPalette.paletteTransform(dex) local spec = ShinyPalette.forDex(dex) + local slide = spec and spec.slide if not spec then return nil end - if not spec.lut then return ShinyPalette.transform(spec) end - local t = ShinyPalette.tintFor(dex) - if not t then return nil end - return function(r, g, b) - return floor(min(255, r * t[1]) + 0.5), - floor(min(255, g * t[2]) + 0.5), - floor(min(255, b * t[3]) + 0.5) + if spec.lut then + if slideCache[dex] == nil then + slideCache[dex] = slideFromLut(spec.lut) or false + end + slide = slideCache[dex] or nil end + if not slide then return nil end + return slideFn({ + h = slide.h or 0, + s = slide.s or 0, + l = (slide.l or 0) * ShinyPalette.PALETTE_LIGHT_DAMP, + }) +end + +-- The measured slide itself, for the tests and for anyone checking the five +-- against Stadium's own textures. +function ShinyPalette.lutSlide(dex) + local spec = ShinyPalette.forDex(dex) + if not (spec and spec.lut) then return nil end + if slideCache[dex] == nil then + slideCache[dex] = slideFromLut(spec.lut) or false + end + return slideCache[dex] or nil end -- ------- the pass over one species' whole texture array diff --git a/lib/ShinyPics.lua b/lib/ShinyPics.lua new file mode 100644 index 0000000..2e102c5 --- /dev/null +++ b/lib/ShinyPics.lua @@ -0,0 +1,156 @@ +-- A shiny's battle pic, genuinely recoloured. +-- +-- ------- why the tint had to go +-- +-- The first answer to "a shiny on the flat art" was a MULTIPLY at draw time, +-- and it was the wrong shape twice over: +-- +-- * A multiply can only DARKEN. Shiny Gyarados is blue turning RED; the +-- nearest a multiply gets is a dimmer blue. Every species whose shiny is +-- lighter, or is a hue rotation rather than a dimming, came out looking +-- like the ordinary one with the brightness down -- which is exactly what +-- "shinies don't work in 2D" describes. +-- * It tinted the whole PICS LAYER, both sides at once, because that is the +-- granularity the engine's own draw has. A shiny facing an ordinary mon +-- dimmed its opponent too. +-- +-- ------- where the colour actually lives +-- +-- The battle pic is not drawn from four-shade art at play time. getImage +-- (src/battle/BattleState.lua:147) snaps the four DMG shades to the species' +-- palette ONCE, with mapPixel, and caches the finished image under +-- `path .. "#" .. pal.name`. By the time anything is drawn the colour is +-- already baked in, and the only way to change it is to hand that bake a +-- different palette -- which also means a different cache NAME, or the shiny +-- and the ordinary pic fight over one cache slot. +-- +-- That is the whole of this file. It is the same conclusion ShinyUI reached +-- for the status screen ("the palette is what has to move"), applied to the +-- one other place a Pokemon is drawn flat. +-- +-- ------- the seam +-- +-- monPalette (BattleState.lua:216) is a local, so it cannot be wrapped. What +-- it calls -- PaletteFX.monPal and PaletteFX.monPalName -- are not, and they +-- are asked in that order for every battle pic the game builds. +-- +-- Neither is told WHICH Pokemon is being drawn; both take a species. The +-- individual arrives one call earlier, at the engine's own `pokemon.sprite` +-- hook, which carries ctx.mon -- so the hook notes "the pic about to be built +-- is this shiny mon's" and the two palette wraps consume that note. A flag +-- rather than an argument, because the argument does not exist. +-- +-- It is consumed ONCE, and matched on species as well, so a leak (monPalette +-- returns early when a species has no palette at all, and then never asks for +-- the name) cannot recolour somebody else's pic -- the worst case is one +-- extra ordinary pic built under a shiny cache key, which the next call +-- corrects. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Shiny = V.require("Shiny") +local ShinyPalette = V.require("ShinyPalette") + +local ShinyPics = {} + +-- { species = , dex = } while a shiny's pic is being built +local pending = nil + +-- The suffix that makes the shiny pic its own cache entry. Part of the +-- palette NAME rather than the path, because the name is what getImage keys +-- on and the path is real art on disk that this mod does not add to. +ShinyPics.SUFFIX = "-SHINY" + +-- ------- what the sprite hook notices +-- +-- Called for every battle pic the engine resolves. Returns nothing: the point +-- is the note it leaves. +function ShinyPics.note(ctx) + pending = nil + if type(ctx) ~= "table" or ctx.kind ~= "battle" then return end + local mon = ctx.mon + if not (mon and Shiny.isShiny(mon)) then return end + local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species] + local dex = def and def.dex + if not dex then return end + pending = { species = ctx.species, dex = dex } +end + +-- Whether the pic currently being built is a shiny's -- for a test, and for +-- the palette wraps below. +function ShinyPics.pendingDex(species) + if pending and pending.species == species then return pending.dex end + return nil +end + +-- ------- the palette wraps +-- +-- Idempotent by sentinel, the pattern every wrap in this mod uses. +function ShinyPics.install() + local ok, PaletteFX = pcall(require, "src.render.PaletteFX") + if not ok or type(PaletteFX) ~= "table" then return false end + if PaletteFX.dramaticShapeShiny then return true end + local innerPal = PaletteFX.monPal + local innerName = PaletteFX.monPalName + if type(innerPal) ~= "function" or type(innerName) ~= "function" then + return false + end + + function PaletteFX.monPal(data, species, transformed, ...) + local cols = innerPal(data, species, transformed, ...) + local dex = ShinyPics.pendingDex(species) + if not (cols and dex) then + -- nothing to recolour, and monPalette's early return means the name + -- wrap below may never run: drop the note here rather than leave it + -- for whoever asks next + if not cols then pending = nil end + return cols + end + local fn = ShinyPalette.paletteTransform(dex) + if not fn then return cols end + -- ------- the first and last shades DO NOT MOVE + -- + -- A Game Boy mon palette is four shades and only the middle two are the + -- Pokemon. The first is the PAPER -- 255,239,255 in every species' + -- palette in the dataset, the white the pic sits on -- and the last is + -- the INK, 25,16,16, the outline every pic is drawn with. Both are shared + -- constants, not colours anybody chose for this animal. + -- + -- Sliding them is what a shiny looks like when it is broken: shiny Golbat + -- rotates far enough that its white became NAVY (31,34,93) and the pic + -- read as a mon on a blue card rather than a green Golbat. Stadium's + -- slides were authored for model textures, which have no paper and no + -- outline in them, so there was nothing there to warn against it. + -- + -- COPIED, never written through, for the rest. monPal hands back the + -- dataset's own palette table, and mutating it would recolour every + -- Pokemon of the species everywhere for the rest of the process -- the + -- same trap ShinyUI's summary wrap documents. + local last = #cols + local out = {} + for i, c in ipairs(cols) do + if type(c) == "table" and c[1] and i > 1 and i < last then + local r, g, b = fn(c[1], c[2], c[3]) + out[i] = { r, g, b } + else + out[i] = c + end + end + return out + end + + function PaletteFX.monPalName(data, species, ...) + local name = innerName(data, species, ...) + local dex = ShinyPics.pendingDex(species) + pending = nil -- consumed: one pic, one note + if not (name and dex) then return name end + if not ShinyPalette.paletteTransform(dex) then return name end + return name .. ShinyPics.SUFFIX + end + + PaletteFX.dramaticShapeShiny = true + return true +end + +return ShinyPics diff --git a/lib/ShinyUI.lua b/lib/ShinyUI.lua index a4ca0f3..a75d578 100644 --- a/lib/ShinyUI.lua +++ b/lib/ShinyUI.lua @@ -97,7 +97,6 @@ end function ShinyUI.install() ShinyUI.installSummary() - ShinyUI.installBattlePics() end -- The status page. Wraps the draw and adds the star afterwards, so the @@ -177,51 +176,25 @@ function ShinyUI.installSummary() SummaryMenu.dramaticShapeShiny = true end --- The battle pics. The engine's pic layer is reached through --- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a --- per-side tint has to bracket each side separately, which is exactly what --- OverworldBattle.sideTexture already does when it renders one side at a --- time into its own canvas. That is where the tint belongs on the 3D path; --- this wrap covers the FLAT path, where the engine draws the battle itself. -function ShinyUI.installBattlePics() - local ok, BattleState = pcall(require, "src.battle.BattleState") - if not ok or type(BattleState) ~= "table" then return end - if BattleState.dramaticShapeShinyPics then return end - local inner = BattleState.drawPicsLayer - if type(inner) ~= "function" then return end - - function BattleState:drawPicsLayer(...) - -- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod - -- is rendering one side into its own canvas it brackets that draw with - -- that side's own tint (OverworldBattle.sideTexture). Tinting again here - -- would square it. Asked as a question rather than left to install - -- order, because both wraps are installed from main.lua and whichever - -- ran first would otherwise silently decide the outcome. - local okOw, Ow = pcall(V.require, "OverworldBattle") - if okOw and Ow and Ow.texturingSide and Ow.texturingSide() then - return inner(self, ...) - end - - -- Both sides at once, so when they disagree the tint cannot be applied - -- per-side here without splitting the engine's own draw. When only ONE - -- side is shiny we tint the whole layer by it: the other side's pic is - -- dimmed slightly, which is far less wrong than a shiny drawn in its - -- ordinary colours -- and when both are shiny each gets the mean. - local data = self.game and self.game.data - local a = self.player and ShinyUI.tintFor(self.player.mon, data) - local b = self.enemy and ShinyUI.tintFor(self.enemy.mon, data) - local tint = a or b - if a and b then - tint = { (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2 } - end - if not tint then return inner(self, ...) end - local args = { ... } - local out - ShinyUI.withTint(tint, function() out = { inner(self, unpack(args)) } end) - return unpack(out or {}) - end - - BattleState.dramaticShapeShinyPics = true -end +-- ------- the battle pics are NOT tinted here any more +-- +-- There used to be a third wrap in this file: a multiply over +-- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason +-- is worth keeping so it is not put back. +-- +-- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the +-- nearest a multiply gets to that is a dimmer blue -- so every species whose +-- shiny is lighter, or is a rotation rather than a dimming, read as the +-- ordinary one with the brightness down. And the engine's pic layer draws +-- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it. +-- +-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where +-- a battle pic's colour actually lives: getImage bakes the four DMG shades +-- into the species palette once and caches the result, so handing that bake a +-- shiny palette (under a cache name of its own) gives a genuinely recoloured +-- pic -- brightening included -- for one side alone. +-- +-- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for +-- the per-side canvas, and they are the only tint left in the mod. return ShinyUI diff --git a/lib/Structures.lua b/lib/Structures.lua index 851de65..814364d 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2004,7 +2004,9 @@ local function stairCell(S, map, data, cx, cy, s) local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 local quads = S.objectQuads - local down = s.class == "stair_down_e" or s.class == "stair_down_w" + local north = s.class == "stair_down_n" + local down = north or s.class == "stair_down_e" + or s.class == "stair_down_w" local east = s.class == "stair_e" or s.class == "stair_down_e" local mx, mz = cx * 16, cy * 16 local h = s.h or 16 @@ -2053,6 +2055,90 @@ local function stairCell(S, map, data, cx, cy, s) end end + -- A flight running INTO the map instead of across it. The drawing is + -- the same staircase seen head-on rather than from the side, and that + -- changes which axis of the art means what: a drawn ROW is a step here, + -- and -- because looking down a well is looking along its depth -- drawn + -- row IS depth row, 1:1 across the cell's 16. + -- + -- The Centers' steps state their own band table and it lands exactly: + -- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So + -- an even four-step division puts a black NOSING on the southmost row of + -- every band (15, 11, 7, 3) and leaves the rows behind it as that step's + -- tread. Nothing is authored but the RISE, which no head-on drawing can + -- state; the depths, the treads and the nosings are all measured. + -- + -- A nosing is drawn as one row because it is seen nearly edge-on, so + -- un-projected it has real height and no depth: its row lies flat as the + -- tread's front lip AND stands as the riser under it. That is the one + -- texel in the flight used twice, and using it twice is what a nosing is. + -- + -- The well's own walls come free as well: the drawing's first and last + -- COLUMNS are its black side walls, and its top band is the darkness the + -- flight leaves by, which is what the far end wants to wear. + -- + -- Every quad here is split at the cell's own 8px seam, in x and in rows + -- both: `uv` resolves ONE tile per corner, and these four tiles are not + -- neighbours in the atlas, so a quad that spans a seam interpolates + -- between two unrelated corners of the sheet. + if north then + local runD = 16 / STAIR_STEPS + local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } } + for i = 0, STAIR_STEPS - 1 do + local a0 = 16 - (i + 1) * runD -- band i, in art rows + local a1 = a0 + runD + local yTop = -(i + 1) * rise + local z0b, z1b = mz + a0, mz + a1 + + for _, H in ipairs(HALVES) do + local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4] + + -- the tread: the whole band, drawn row = depth row, so the nosing + -- lies on its front lip exactly where the artist drew it + face({ wx0, yTop, z0b }, { wx1, yTop, z0b }, + { wx1, yTop, z1b }, { wx0, yTop, z1b }, + ax0, a1, ax1, a0, STAIR_SHADE.wellTread) + + -- the riser under that lip. It faces NORTH -- a flight descending + -- away from you turns its risers away with it, and they close the + -- steps from below rather than being looked at. One art row tall, + -- so it needs none of `banded`'s row splitting; written straight + -- keeps the geometry flush at the seam while the art stays inside + -- its tile + local ry = -i * rise + face({ wx1, yTop, z1b }, { wx0, yTop, z1b }, + { wx0, ry, z1b }, { wx1, ry, z1b }, + ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser) + + -- the deep end, closing the opening this flight is cut into: from + -- the floor of the well up to the top of the wall band beside it, + -- in the drawing's own black top rows + if i == STAIR_STEPS - 1 then + face({ wx1, -h, mz }, { wx0, -h, mz }, + { wx0, h, mz }, { wx1, h, mz }, + ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd) + end + end + + -- the well's side walls above this tread, wearing the drawing's own + -- black edge columns -- the excavation is walled in its own texels + local function sideWall(px, sx0, sx1, inward) + local c + if inward then -- west wall, faces E + c = { { px, yTop, z1b }, { px, yTop, z0b }, + { px, 0, z0b }, { px, 0, z1b } } + else -- east wall, faces W + c = { { px, yTop, z0b }, { px, yTop, z1b }, + { px, 0, z1b }, { px, 0, z0b } } + end + face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN) + end + sideWall(mx, 0.1, 1.3, true) + sideWall(mx + 16, 14.7, 15.9, false) + end + return + end + for i = 0, STAIR_STEPS - 1 do local sx0 = east and (i * runW) or (16 - (i + 1) * runW) local sx1 = sx0 + runW @@ -2152,6 +2238,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1) -- box or floor it. A rising flight stands on the map's common -- floor; a stairwell IS the hole, so nothing is painted under it local down = s.class == "stair_down_e" or s.class == "stair_down_w" + or s.class == "stair_down_n" for dy = 0, 1 do for dx = 0, 1 do local tk = keyOf(cx * 2 + dx, cy * 2 + dy) @@ -2253,6 +2340,43 @@ function Structures.buildVolume(S, map, tiles) -- whether the region's dominant columns are flat repeats (a cliff -- mound's plateau) rather than drawn facades (a house's front) local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN + + -- Whether this REGION's tops are a rim over a uniform body -- what every + -- cliff mound is drawn as: a top edge, then the same rock the whole way + -- down. The top face may then lay that rim once along its north edge and + -- hold the body after it, instead of cycling the rim back every second + -- tile and striping a plateau with edges it should not have. + -- + -- Answered per column AND per region, because each catches what the + -- other misses. A mound is one structure many columns wide, and the + -- columns carrying its cave mouth read differently from their neighbours + -- (their drawing ends in the mouth's own tiles): per column alone, those + -- kept cycling while the rest held, leaving rim stubs above the doorway. + -- But a region vote alone silences a genuine rim-over-body column that + -- happens to stand in a region of repeating art -- three of them in the + -- Safari Zone. A column holds if EITHER says so. + -- + -- Art that genuinely repeats is not uniform and keeps cycling: the + -- Safari Zone's fence alternates two tiles the whole way down, and there + -- the repeat IS what the drawing says. + local uniformVotes, uniformTotal = 0, 0 + for _, r in ipairs(runs) do + local run = r.run + if run.extent > 2 then + uniformTotal = uniformTotal + 1 + local body = map:tileAt(r.tx, run.north + 1) + local uniform = true + for d = 2, run.extent - 1 do + if map:tileAt(r.tx, run.north + d) ~= body then + uniform = false + break + end + end + run.ownUniform = uniform + if uniform then uniformVotes = uniformVotes + 1 end + end + end + local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal for _, r in ipairs(runs) do local run = r.run local h = run.unit * 8 @@ -2300,6 +2424,7 @@ function Structures.buildVolume(S, map, tiles) run.rise = roofRows * 8 run.peak = h run.h = h - run.rise -- facade height: what sides build to + run.topUniform = run.ownUniform or regionUniform for ty = run.north, run.front do S.runs[keyOf(r.tx, ty)] = run end @@ -3051,6 +3176,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D) end end +-- An AUTHORED solid standing on furniture, given as plan layers instead of +-- extruded from the drawing (see TileShape's `model`). The one thing it +-- shares with the mask paths is that nothing here is a colour: each layer +-- names the atlas texels its top and its sides wear, and every quad below +-- samples one of them, so the Centers' bell is painted out of the counter's +-- own pixels and recolours with it. +-- +-- Placement is by CELL, not by drawn row. A model exists because the +-- drawing was too small to un-project, so its drawn row says nothing about +-- depth worth keeping -- what says something is which piece of furniture it +-- is on and which end of it a person reaches: the solid is centred on the +-- mask's own columns and pushed to the SOUTH edge of the support cell, the +-- face the aisle is on, less the entry's `inset` -- the one number here +-- taste can move, because flush against the counter's own front lip is a +-- real position and so is a couple of voxels back from it. +local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0) + local function uvOf(t) + local tile, row, col = t[1], t[2], t[3] or 0 + return ((tile % perRow) * 8 + col + 0.5) / atlasW, + (math.floor(tile / perRow) * 8 + row + 0.5) / atlasH + end + + for k, L in ipairs(m) do + local u, v = uvOf(L.side) + local ut, vt = uvOf(L.top) + local above = m[k + 1] + local x0 = xMid - math.floor(L.w / 2) + local z0 = zSouth - L.d + local function solid(layer, dx, dz) + if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then + return false + end + return layer.cells[dz * layer.w + dx] or false + end + for dz = 0, L.d - 1 do + for dx = 0, L.w - 1 do + if solid(L, dx, dz) then + local x, y, z = x0 + dx, y0 + k - 1, z0 + dz + local function quad(c1, c2, c3, c4, uu, vv, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv, + shade = shade } + end + -- a layer's own plan is what closes it: a face is drawn wherever + -- the neighbouring cell of this layer is empty, and the top + -- wherever the layer ABOVE does not stand on it. Nothing needs a + -- bottom -- layer 1 rests on the furniture and the rest rest on + -- each other. + if not solid(above, dx, dz) then + quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 }, + { x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top) + end + if not solid(L, dx, dz + 1) then + quad({ x, y, z + 1 }, { x + 1, y, z + 1 }, + { x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v, + OBJ_SHADE.front) + end + if not solid(L, dx, dz - 1) then + quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z }, + { x + 1, y + 1, z }, u, v, OBJ_SHADE.back) + end + if not solid(L, dx - 1, dz) then + quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 }, + { x, y + 1, z }, u, v, OBJ_SHADE.side) + end + if not solid(L, dx + 1, dz) then + quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z }, + { x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side) + end + end + end + end + end +end + -- ---- figures: a thing drawn INTO furniture, cut out and stood up ---- -- One authored figure at one matched position. @@ -3133,7 +3332,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow) local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 - if fig.depth then + if fig.model then + -- An authored solid: centred on the mask's own columns, standing on + -- the furniture's top plane at the front of its cell. + local maxX = minX + for ly = 0, bh - 1 do + for lx = 0, bw - 1 do + if at(lx, ly) and lx > maxX then maxX = lx end + end + end + local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2) + local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0) + maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH, + xMid, zSouth, baseY) + elseif fig.depth then -- An OBJECT: the standee slab, standing on the FRONT edge of the tile -- row its feet are drawn in -- the south face of the 8px band a -- character card would have pivoted in. It is anchored there and diff --git a/lib/TileShape.lua b/lib/TileShape.lua index f9e041e..c0c19ce 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -125,6 +125,12 @@ local FALLBACK_HEIGHTS = { stair_w = 16, stair_down_e = 16, stair_down_w = 16, + -- a stairwell descending toward the BACK of the map, drawn head-on + -- instead of from the side (the Centers' Cable Club steps). Its own + -- class because the art reading is not the east/west one turned: there + -- a drawn COLUMN is a step and a drawn row is height, here a drawn ROW + -- is a step and drawn row = depth row, 1:1 down the well + stair_down_n = 16, } -- class -> how the mesher draws it (see the header). The last three are @@ -215,6 +221,7 @@ local ART = { stair_w = "stair", stair_down_e = "stair", stair_down_w = "stair", + stair_down_n = "stair", } local spec = nil -- the loaded data file, or false when absent @@ -469,6 +476,8 @@ end -- -- figures = { { w = , -- depth = , +-- model = { ...authored plan layers, bottom first... }, +-- inset = , -- thin = { rows = , depth = }, -- flat = { x = { , }, rows = { , } }, -- tiles = { ...w*h tile ids, row-major... }, @@ -488,6 +497,16 @@ end -- same furniture the card would have stood on. The Marts' cash -- register is the case: a machine on a counter is a box, not an icon. -- +-- `model` is the third answer, and the only one that is not an extrusion +-- of the drawing at all: an AUTHORED solid, given as plan layers bottom +-- first, standing at the FRONT of the support cell. It exists for a +-- drawing too small to un-project -- the Centers' push bell is 7x6 pixels +-- of ¾-view dome, and no reading of six rows produces a shape a mask can +-- extrude without inventing more than it measures. What it still may not +-- invent is COLOUR: each layer names the atlas texel its top and its +-- sides wear, so the solid is painted out of the drawing it replaces and +-- follows every palette bake exactly like the rest of this file. +-- -- Two fields say which parts of such a drawing are NOT the extrusion, -- because a solid drawn in one 16x16 GB cell still packs more than one -- facing: @@ -557,10 +576,43 @@ local function authoredMasks(list) r0 = math.floor(f.flat.rows[1]), r1 = math.floor(f.flat.rows[2]) } end + -- an AUTHORED model: plan layers bottom-first, each with the atlas + -- texel its top and its sides wear. Dropped whole on any malformed + -- layer, like every other field here -- a typo should leave the + -- drawing lying flat, not build half a solid. + local model = nil + if type(f.model) == "table" and #f.model > 0 then + model = {} + for _, L in ipairs(f.model) do + local plan = type(L) == "table" and L.plan + local mw = (type(plan) == "table" and type(plan[1]) == "string") + and #plan[1] or 0 + local okL = mw > 0 and type(L.top) == "table" + and type(L.side) == "table" + if okL then + for _, r in ipairs(plan) do + if type(r) ~= "string" or #r ~= mw then okL = false break end + end + end + if not okL then model = nil break end + local cells = {} + for dz = 0, #plan - 1 do + local r = plan[dz + 1] + for dx = 0, mw - 1 do + if r:sub(dx + 1, dx + 1) ~= "0" then cells[dz * mw + dx] = true end + end + end + model[#model + 1] = { w = mw, d = #plan, cells = cells, + top = L.top, side = L.side } + end + end if n > 0 then out[#out + 1] = { w = w, h = h, n = n, mask = mask, tiles = f.tiles, under = f.under, depth = depth and math.floor(depth) or nil, + model = model, + inset = model and math.floor(tonumber(f.inset) or 0) + or nil, thin = thin, flat = flat } end end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 5b3d7f9..3e690cd 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -503,12 +503,22 @@ local active = false -- which is exactly the old behaviour minus the reflections. local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } +-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more: +-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound +-- together must agree on PIXEL dimensions. The colour canvas beside this one +-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1 +-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this +-- one came back 2.625x larger and the pair would not bind. beginScene then +-- dropped the readable depth for the session (see below), depthReadable() +-- went false, and the water pass never ran at all: the reflections were +-- missing on every high-density display, with nothing in the log to say so, +-- because a canvas that will not BIND is not a canvas the driver refused. local function newDepth(w, h) if not (love.graphics and love.graphics.newCanvas) then return nil end local c = nil for _, format in ipairs(DEPTH_FORMATS) do local ok, made = pcall(love.graphics.newCanvas, w, h, - { format = format, readable = true }) + { format = format, readable = true, dpiscale = 1 }) if ok and made then c = made break end end if not c then return nil end @@ -1344,7 +1354,10 @@ end function Voxel3D.beginWater(paint) if not (active and canvas and held and held.depth) then return nil end if not held.mirror then - local ok, c = pcall(love.graphics.newCanvas, held.w, held.h) + -- through PixelCanvas, because this one is bound WITH held.depth a few + -- lines down and the two must agree on pixel dimensions -- the same + -- scale trap newDepth documents + local ok, c = PixelCanvas.new(held.w, held.h) if not (ok and c) then return nil end pcall(c.setFilter, c, "nearest", "nearest") pcall(c.setWrap, c, "clamp", "clamp") diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index b585072..fdaf68c 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -61,19 +61,13 @@ end VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL, { false, true }, { "OFF", "ON" }) --- A pass that needs the wireframe whatever the player left the row on sets --- this for the length of its own draw and puts it back after. nil means --- "follow the setting", which is every frame outside such a pass. --- --- The overworld battle is the one user: a fight is a STAGED shot, not the --- world being walked around in, and the seams are what make it read as --- constructed rather than as a photograph of somewhere. The row still owns --- what free-roam looks like, and is not written to -- switching the mode off --- mid-battle would silently rewrite the player's own setting. -VoxelGrid.override = nil - +-- The row is the whole answer, everywhere: free-roam and the battle arena +-- alike. The battle used to force the seams on regardless -- a fight is a +-- STAGED shot, and the seams are what make it read as constructed rather +-- than photographed -- but a player who turns the wireframe off means the +-- whole mod, and a mode that came back for every fight read as the row not +-- working rather than as a deliberate framing. function VoxelGrid.enabled() - if VoxelGrid.override ~= nil then return VoxelGrid.override end return VoxelGrid.setting:get() and true or false end diff --git a/main.lua b/main.lua index 33d1842..2cb7903 100644 --- a/main.lua +++ b/main.lua @@ -91,6 +91,8 @@ local BattleExit = V.require("BattleExit") local Shiny = V.require("Shiny") local ShinyBattle = V.require("ShinyBattle") local ShinyUI = V.require("ShinyUI") +local ShinyPics = V.require("ShinyPics") +local ShinyFlash = V.require("ShinyFlash") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") @@ -1185,14 +1187,35 @@ OverworldBattle.install() -- ShinyBattle wraps Pokemon.new, which is where every wild, gift, -- starter and traded mon is built, so the roll lands before -- the sprite is baked --- ShinyUI the battle pics' tint and the status page's mark --- ShinyFx the arrival sparkle (armed from Stadium.update) +-- ShinyUI the status page's mark, and the summary pic's palette +-- ShinyPics the battle pic's palette -- a real recolour, baked into the +-- image cache under a shiny key, on every rung that draws a +-- pic (OFF, both 2D-3D rungs, and the cards a STADIUM battle +-- still uses for a species with no model) +-- ShinyFx the arrival sparkle for the STADIUM rungs (3D, armed from +-- Stadium.update) +-- ShinyFlash the same announcement for every OTHER rung, drawn in the +-- Game Boy's own pixel grid over the pic -- -- The Stadium models need no seam here at all: their recolour happens at -- extraction (lib/StadiumBuild.lua), and the battle simply asks for the -- shiny pack. ShinyBattle.install() ShinyUI.install() +ShinyPics.install() +ShinyFlash.install() + +-- ShinyPics needs to know WHICH Pokemon a pic is being built for, and the +-- two palette functions it wraps are told only the species. The individual +-- passes through here one call earlier: `pokemon.sprite` carries ctx.mon. +-- +-- next() first and the return value untouched -- this reads the context and +-- changes nothing about which art is chosen. +mod.hooks:wrap("pokemon.sprite", function(next, path, ctx) + local out = next(path, ctx) + pcall(ShinyPics.note, ctx) + return out +end) -- A save opened for the first time under this mod has shiny Pokemon in it -- already -- they always did -- so refresh the cached flag across the party diff --git a/manifest.json b/manifest.json index f0a48fb..12ae7a3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.8.0", + "version": "1.8.1", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index e121062..2f3ed88 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -3631,25 +3631,20 @@ T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 2 }), false, T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 5 }), true, "on a four-frame cycle") --- ------- the wireframe is forced on in a battle +-- ------- the wireframe follows the V-GRID row, battles included -- --- A fight is a staged shot rather than the world being walked through, so it --- always wears the seams. The player's own V-GRID row must not be touched by --- that -- an override, not a write, or switching the mode off mid-battle --- would quietly rewrite a setting they chose. +-- The row is the whole answer. A battle used to force the seams on whatever +-- it said -- so nothing may override enabled(), and the battle pass must not +-- write the row either: a fight that flipped it would rewrite a setting the +-- player chose. local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") -Grid.override = nil -local rowWas = Grid.setting:get() -T.eq(Grid.enabled(), rowWas and true or false, - "with no override the wireframe follows the row") -Grid.override = true -T.eq(Grid.enabled(), true, "an override forces it on") -T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone") -Grid.override = false -T.eq(Grid.enabled(), false, "an override can force it off too") -Grid.override = nil -T.eq(Grid.enabled(), rowWas and true or false, - "and clearing it hands the answer back to the row") +local idxWas = Grid.setting.index +Grid.setting:sync(true) +T.eq(Grid.enabled(), true, "the wireframe is on when the row is on") +Grid.setting:sync(false) +T.eq(Grid.enabled(), false, "and off when the row is off -- nothing overrides it") +T.eq(Grid.override, nil, "and there is no override left to force a battle on") +Grid.setting.index = idxWas -- ------- the depth of field is measured off the two marks -- diff --git a/tests/flat_top_test.lua b/tests/flat_top_test.lua new file mode 100644 index 0000000..14035c0 --- /dev/null +++ b/tests/flat_top_test.lua @@ -0,0 +1,95 @@ +-- A flat top must not stamp its rim twice. +-- +-- ChunkMesher.flatTopRow decides which drawn row a flat-topped volume's top +-- face wears at each depth. Where the drawing is a RIM over a uniform body +-- -- every cliff mound in the game, and the mound the Diglett's Cave mouth +-- is cut into -- the rim belongs at the plateau's north edge and nowhere +-- else. Cycling the first two rows lays it again every second tile. +-- +-- The invariant: on such a run the sampled row never goes BACKWARDS as ty +-- moves south. Art that genuinely repeats (the Safari Zone's fence +-- alternates two tiles the whole way down) is exempt: there the repeat is +-- what the drawing says, and the run is not rim-over-body. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/flat_top_test.lua lovec . +return function(game) + local U = dofile("tests/drivers/util.lua") + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + local Structures = V and V.require("Structures") + local ChunkMesher = V and V.require("ChunkMesher") + if not (Structures and ChunkMesher and ChunkMesher.flatTopRow) then + print("[flattop] FAIL mod, Structures or ChunkMesher.flatTopRow missing") + love.event.quit(1) + return + end + local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end + + local MAPS = {} + for id in pairs((game.data and game.data.maps) or {}) do + MAPS[#MAPS + 1] = id + end + table.sort(MAPS) + + local checked, offenders, examples = 0, 0, {} + for _, mapId in ipairs(MAPS) do + U.teleport(game, mapId, 5, 5, "up") + U.wait(6) + local ow = game.overworld + if ow and ow.map and ow.map.def and ow.map.def.id == mapId then + local map = ow.map + local S = Structures.forMap(map) + local seen = {} + for tx = 0, map.def.width * 4 - 1 do + for ty = 0, map.def.height * 4 - 1 do + local run = S.runs[keyOf(tx, ty)] + local sig = run and (tostring(run) .. ":" .. tx) + if run and not seen[sig] and (run.rise or 0) == 0 then + seen[sig] = true + local ext = run.front - run.north + 1 + -- rim over a uniform body: the shape the rim must not repeat on + local uniform = ext > 2 + if uniform then + local body = map:tileAt(tx, run.north + 1) + for d = 2, ext - 1 do + if map:tileAt(tx, run.north + d) ~= body then + uniform = false + break + end + end + end + if uniform then + checked = checked + 1 + local prev = -1 + for ty2 = run.north, run.front do + local row = ChunkMesher.flatTopRow(run, ty2) + if row < prev then + offenders = offenders + 1 + if #examples < 5 then + examples[#examples + 1] = ("%s tx=%d north=%d ext=%d " + .. "went back to row %d at ty %d") + :format(mapId, tx, run.north, ext, row, ty2) + end + break + end + prev = row + end + end + end + end + end + end + end + + print(("[flattop] %d rim-over-body runs checked, %d repeat their rim") + :format(checked, offenders)) + for _, e in ipairs(examples) do print("[flattop] " .. e) end + if offenders > 0 then + print("[flattop] FAIL") + love.event.quit(1) + else + print("[flattop] PASS") + love.event.quit(0) + end +end diff --git a/tests/shiny_flat.lua b/tests/shiny_flat.lua new file mode 100644 index 0000000..8789e3c --- /dev/null +++ b/tests/shiny_flat.lua @@ -0,0 +1,119 @@ +-- Driver: is a shiny visible on the FLAT paths -- the engine's own battle +-- screen (3D-BTL OFF) and the cards rung (2D-3D A)? +-- +-- DS_SHOTS=mods/DramaticShapeVoxelMod/.claude/shiny_update/flat \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_flat.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- The other shot driver covers the cards rung and the STADIUM rungs and NOT +-- 3D-BTL OFF -- which is the rung a player who has never touched the mod's +-- battle row is on, and so the one place a regression can sit unseen. It sat +-- there: the pic tint was a multiply that could only darken, and the arrival +-- sparkle was armed from Stadium.update and therefore never played here. +-- +-- Each rung is shot TWICE, shiny and ordinary, from the same species at the +-- same spot, as a STRIP -- the intro flashes the pic through palette variants +-- on its way in, so a single frame lands wherever the pacing put it. +-- +-- Reports, per rung: whether the roll landed, the PALETTE the pic was baked +-- under (which is where the recolour now lives), and whether the flat-path +-- sparkle armed and drew. +return function(game) + local U = dofile("tests/drivers/util.lua") + local BattleState = require("src.battle.BattleState") + local PaletteFX = require("src.render.PaletteFX") + local Pokemon = require("src.pokemon.Pokemon") + + local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS" + local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 40 + local DIR = os.getenv("DS_SHOTS") or ".claude/shiny_update/flat" + + 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") return end + local Shiny = lib.require("Shiny") + local ShinyPics = lib.require("ShinyPics") + local ShinyFlash = lib.require("ShinyFlash") + local OverworldBattle = lib.require("OverworldBattle") + + U.log(("wraps: pics=%s flash=%s"):format( + tostring(PaletteFX.dramaticShapeShiny == true), + tostring(ShinyFlash.installed == true))) + + -- ------- what the palette wrap hands the image cache + -- + -- The recolour is baked ONCE, at build time, so counting draws says nothing + -- about it. What matters is the cache key and the colours behind it: ask + -- PaletteFX the same two questions monPalette asks, with the note the + -- sprite hook would have left a moment earlier. + local function palReport(mon) + ShinyPics.note({ kind = "battle", species = SPECIES, mon = mon, + data = game.data }) + local cols = PaletteFX.monPal(game.data, SPECIES) + local name = PaletteFX.monPalName(game.data, SPECIES) + local out = { "pal=" .. tostring(name) } + for i = 1, math.min(3, cols and #cols or 0) do + local c = cols[i] + if type(c) == "table" and c[1] then + out[#out + 1] = ("c%d=%d,%d,%d"):format(i, c[1], c[2], c[3]) + end + end + return table.concat(out, " ") + end + + -- The party is built at ORDINARY odds and pinned afterwards, so the + -- player's own Pikachu stays common: this run is about the foe. + game.save.player.name = "RED" + game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) } + + local function leave() + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + local function shoot(rung, label, shiny) + OverworldBattle.setting:setValue(rung, game) + Shiny.setOdds(shiny and 1 or 100000000) + for k in pairs(ShinyFlash.debug) do ShinyFlash.debug[k] = 0 end + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(60) + + local battle = BattleState.newWild(game, SPECIES, LEVEL) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + local mon = battle.enemy and battle.enemy.mon + U.log(("%s %s: 3D-BTL=%s isShiny=%s %s"):format( + label, shiny and "shiny" or "normal", + tostring(OverworldBattle.setting:get()), tostring(Shiny.isShiny(mon)), + palReport(mon))) + + -- The WIPE has to be walked through first. A driver run with no input at + -- all sits on BattleTransition forever -- the battle is never pushed, so + -- nothing about it draws and every counter below reads zero, which is + -- exactly the false negative this probe produced before the taps went in. + for _ = 1, 8 do U.tap(game, "a") U.wait(10) end + + -- the sparkle is three quarters of a second long and starts on the frame + -- the pic appears, so the strip is TIGHT + for k = 1, 10 do + U.shot(game, ("%s/%s_%s_%02d.png"):format(DIR, label, + shiny and "shiny" or "normal", + k)) + U.wait(9) + end + local d = ShinyFlash.debug + U.log((" flash: renders=%s armed=%d draws=%d sparks=%d follows=%d occ=%d %s") + :format(tostring(d.renders), d.armed, d.draws, d.sparks, + d.follows, d.occupied, tostring(d.err))) + leave() + end + + shoot(false, "off", false) + shoot(false, "off", true) + shoot(true, "cards", false) + shoot(true, "cards", true) +end diff --git a/tools/shiny_palette_sheet.lua b/tools/shiny_palette_sheet.lua new file mode 100644 index 0000000..b7151c3 --- /dev/null +++ b/tools/shiny_palette_sheet.lua @@ -0,0 +1,188 @@ +-- Every species' battle palette, normal beside shiny, as one HTML page. +-- +-- luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua +-- +-- Run from the PROJECT ROOT. Writes +-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html +-- +-- ------- what it is actually showing +-- +-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's +-- values for a model's texels, and it is already checked against the Python +-- that produced it. This is the other end: what those values become after +-- ShinyPics puts them through the engine's four-shade battle palette, which +-- is where the flat art gets its colour and the only place a mistake there +-- shows up. +-- +-- Two rules are visible in the output and both were bugs first: +-- +-- * shade 1 and shade 4 never move. They are the shared paper (255,239,255) +-- and the shared ink (25,16,16), not colours anybody chose for this +-- animal, and sliding them turned shiny Golbat's white navy. +-- * the five TABLE species rotate hue like everyone else, because their +-- slide is measured back out of their lookup table rather than falling +-- back to a multiply that can only darken. +-- +-- The COLORS pack is whatever PaletteFX defaults to in a headless process +-- (the GBC pack). The RED++ pack is a different set of four colours per +-- species and would want its own sheet. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local MOD = "mods/DramaticShapeVoxelMod" +local OUT = MOD .. "/.claude/shiny_update/palettes.html" + +-- ------- the mod namespace, enough of it (see tests/shiny_test.lua) +local loaded, V = {}, {} +function V.require(n) + if loaded[n] == nil then + loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V) + end + return loaded[n] +end +function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end +V.path = MOD +V.mod = { id = "DRAMATIC_SHAPE", + log = { warn = function() end, info = function() end } } + +local ShinyPics = V.require("ShinyPics") +local ShinyPalette = V.require("ShinyPalette") +local PaletteFX = require("src.render.PaletteFX") + +local Data = { + pokemon = dofile("data/generated/pokemon.lua"), + palettes = dofile("data/generated/palettes.lua"), +} + +assert(ShinyPics.install(), "the palette wrap did not install") + +-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua +-- reads; any mon carrying it is shiny as far as the engine is concerned. +local SHINY = { dvs = { attack = 10, defense = 10, speed = 10, + special = 10, hp = 15 } } + +-- ------- collect, in dex order +local rows = {} +for name, def in pairs(Data.pokemon) do + if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then + rows[#rows + 1] = { name = name, dex = def.dex } + end +end +table.sort(rows, function(a, b) return a.dex < b.dex end) + +local function hex(c) + return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0) +end + +local moved, still, missing = 0, 0, 0 + +for _, row in ipairs(rows) do + row.normal = PaletteFX.monPal(Data, row.name) + row.palName = PaletteFX.monPalName(Data, row.name) + ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY, + data = Data }) + row.shiny = PaletteFX.monPal(Data, row.name) + row.shinyName = PaletteFX.monPalName(Data, row.name) + + local spec = ShinyPalette.forDex(row.dex) + row.kind = spec and (spec.lut and "table" or "slide") or "none" + local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex) + or spec.slide) + row.slide = slide + + if not (row.normal and row.shiny) then + missing = missing + 1 + else + -- how far the two middle shades actually travelled, as the largest + -- per-channel step: a row that reads 0 is a shiny nobody can see + local d = 0 + for i = 2, #row.normal - 1 do + local a, b = row.normal[i], row.shiny[i] + if type(a) == "table" and type(b) == "table" then + for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end + end + end + row.delta = d + if d >= 8 then moved = moved + 1 else still = still + 1 end + end +end + +-- ------- the page +local out = {} +local function w(s) out[#out + 1] = s end + +w([[ + +Shiny battle palettes + +

Shiny battle palettes — normal beside shiny

+

What ShinyPics hands the battle pic cache, per +species. The first and last shades are the shared paper and ink and are held +still on purpose (shown faded); only the two middle shades are the Pokemon. +slide species use Stadium's declared values; +the five table species use a slide measured +back out of their own lookup table, which is what lets Gyarados reach red. +Δ is the largest per-channel step across the two middle shades — +a row in red barely moved.

+]]) + +w(("

%d species · %d visibly recoloured · " + .. "%d barely moved · %d with no palette

\n") + :format(#rows, moved, still, missing)) + +w("" + .. "" + .. "\n") + +for _, row in ipairs(rows) do + local function swatches(cols) + if not cols then return "—" end + local o = {} + for i, c in ipairs(cols) do + local fixed = (i == 1 or i == #cols) and " fixed" or "" + if type(c) == "table" and c[1] then + o[#o + 1] = ("") + :format(fixed, hex(c), c[1], c[2], c[3]) + end + end + return table.concat(o) + end + local s = row.slide + w(("" + .. "" + .. "\n") + :format(row.dex, row.name, tostring(row.palName), row.kind, row.kind, + s and ("%.0f° / %+.1f / %+.1f"):format(s.h or 0, s.s or 0, + s.l or 0) or "—", + swatches(row.normal), swatches(row.shiny), + (row.delta and row.delta < 8) and " flat" or "", + row.delta and tostring(row.delta) or "—")) +end + +w("
#speciespalkindslide h / s / lnormalshinyΔ
%03d%s%s%s%s%s%s%s
\n") + +local f = assert(io.open(OUT, "wb")) +f:write(table.concat(out)) +f:close() + +print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette") + :format(OUT, #rows, moved, still, missing)) diff --git a/tools/shiny_pic_dump.lua b/tools/shiny_pic_dump.lua new file mode 100644 index 0000000..fcd75ab --- /dev/null +++ b/tools/shiny_pic_dump.lua @@ -0,0 +1,75 @@ +-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics. +-- +-- luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv +-- +-- Run from the PROJECT ROOT. One species per line, tab separated: +-- +-- dex name spriteFront kind n1 n2 n3 n4 s1 s2 s3 s4 +-- +-- where each colour is r,g,b. TSV rather than JSON because there is no JSON +-- encoder in this tree and the payload is eight colours and a path. +-- +-- The COLOURS are the point: they come from the real wrap (ShinyPics over +-- PaletteFX.monPal), not from a second implementation of it, so what the +-- sheet shows is what the game bakes. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local MOD = "mods/DramaticShapeVoxelMod" + +local loaded, V = {}, {} +function V.require(n) + if loaded[n] == nil then + loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V) + end + return loaded[n] +end +function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end +V.path = MOD +V.mod = { id = "DRAMATIC_SHAPE", + log = { warn = function() end, info = function() end } } + +local ShinyPics = V.require("ShinyPics") +local ShinyPalette = V.require("ShinyPalette") +local PaletteFX = require("src.render.PaletteFX") + +local Data = { + pokemon = dofile("data/generated/pokemon.lua"), + palettes = dofile("data/generated/palettes.lua"), +} + +assert(ShinyPics.install(), "the palette wrap did not install") + +local SHINY = { dvs = { attack = 10, defense = 10, speed = 10, + special = 10, hp = 15 } } + +local rows = {} +for name, def in pairs(Data.pokemon) do + if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 + and def.spriteFront then + rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront } + end +end +table.sort(rows, function(a, b) return a.dex < b.dex end) + +local function cols(t) + local o = {} + for i = 1, 4 do + local c = t and t[i] + o[i] = (type(c) == "table" and c[1]) + and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0" + end + return table.concat(o, "\t") +end + +for _, row in ipairs(rows) do + local normal = PaletteFX.monPal(Data, row.name) + ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY, + data = Data }) + local shiny = PaletteFX.monPal(Data, row.name) + local spec = ShinyPalette.forDex(row.dex) + local kind = spec and (spec.lut and "table" or "slide") or "none" + io.write(("%d\t%s\t%s\t%s\t%s\t%s\n") + :format(row.dex, row.name, row.path, kind, cols(normal), + cols(shiny))) +end diff --git a/tools/shiny_pic_sheet.py b/tools/shiny_pic_sheet.py new file mode 100644 index 0000000..5de6239 --- /dev/null +++ b/tools/shiny_pic_sheet.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Every battle pic, baked normal beside baked shiny, as one HTML page. + + luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv + python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv + +Run from the PROJECT ROOT. Writes +mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained +(every pic is a data: URI), so it can be opened or sent on its own. + +------- the bake is the engine's, exactly + +src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets +its colour, and it does it ONCE at load with mapPixel: + + col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4] + +Four-shade DMG art, keyed on the RED channel alone, snapped to the species +palette. That line is reproduced below rather than approximated, because the +whole question this sheet answers is what the player will actually see -- an +approximation of the bake would be answering a different one. + +The colours come from tools/shiny_pic_dump.lua, which runs the real +ShinyPics wrap over the real PaletteFX, so nothing here re-derives them. +""" + +import base64 +import io +import os +import sys + +from PIL import Image + +ROOT = os.getcwd() +MOD = "mods/DramaticShapeVoxelMod" +OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html") +SCALE = 3 # nearest-neighbour, so the pixels stay pixels + + +def parse_color(s): + r, g, b = (int(v) for v in s.split(",")) + return (r, g, b) + + +def bake(img, pal): + """getImage's mapPixel: red channel picks the shade, alpha is kept.""" + img = img.convert("RGBA") + px = img.load() + w, h = img.size + for y in range(h): + for x in range(w): + r, g, b, a = px[x, y] + if a == 0: + continue + f = r / 255.0 + if f > 0.83: + c = pal[0] + elif f > 0.5: + c = pal[1] + elif f > 0.17: + c = pal[2] + else: + c = pal[3] + px[x, y] = (c[0], c[1], c[2], a) + return img + + +def data_uri(img): + img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST) + buf = io.BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + + +def main(): + src = sys.argv[1] if len(sys.argv) > 1 else "-" + stream = sys.stdin if src == "-" else open(src, encoding="utf-8") + rows = [] + with stream: + for line in stream: + line = line.rstrip("\n") + if not line: + continue + f = line.split("\t") + rows.append({ + "dex": int(f[0]), + "name": f[1], + "path": f[2], + "kind": f[3], + "normal": [parse_color(c) for c in f[4:8]], + "shiny": [parse_color(c) for c in f[8:12]], + }) + + cards, missing = [], 0 + for row in rows: + path = os.path.join(ROOT, row["path"]) + if not os.path.exists(path): + missing += 1 + continue + art = Image.open(path) + n = data_uri(bake(art.copy(), row["normal"])) + s = data_uri(bake(art.copy(), row["shiny"])) + cards.append( + '
' + '
{name} normal' + '{name} shiny
' + '
{dex:03d} {name}{kind}
' + "
".format(kind=row["kind"], n=n, s=s, + name=row["name"], dex=row["dex"]) + ) + + html = """ +Shiny battle pics + +

Shiny battle pics — normal on the left, shiny on the right

+

Each pair is the same four-shade art baked twice, through the +palette the game itself would use: getImage keys on the red +channel alone and snaps to the species palette, once, at load. The colours +come from the live ShinyPics wrap, so this is what the flat +battle screen draws — not a preview of it. Bordered cards are the five +species Stadium gives a real alternate texture, whose slide is measured back +out of that texture rather than declared.

+

%d species%s

+
%s
+ +""" % (len(cards), + "" if not missing else " · %d with no art on disk" % missing, + "\n".join(cards)) + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + f.write(html) + size = os.path.getsize(OUT) / 1024.0 + print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing, + size)) + + +if __name__ == "__main__": + main()