From 20f9e19bf91e4bd99aa1568c034939cf8266f751 Mon Sep 17 00:00:00 2001 From: Code-Grub <34581585+Code-Grub@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:48:06 -0400 Subject: [PATCH 1/7] stop a flat top stamping its rim down the plateau A cliff mound is drawn as a rim over a body: its top edge, then the same rock the whole way down. The top face cycles the first two drawn rows to fill its depth, so it laid that rim again every second tile. The mound the Diglett's Cave mouth is cut into came out with three rim lines across it instead of one along its north edge. Where the drawing says the body is all one tile, lay the rim once and hold the body after it. Art that genuinely repeats keeps cycling: the Safari Zone's fence alternates two tiles the whole way down, and there the repeat is what the drawing says. Answered per column and per region, because each catches what the other misses. The columns carrying a mound's cave mouth end in the mouth's own tiles, so per column alone they kept cycling while their neighbours held, leaving rim stubs above the doorway. A region vote alone silences a real rim-over-body column standing in a region of repeating art, of which the Safari Zone has three. A column holds if either says so. Geometry is untouched: the silhouette is pixel for pixel what it was, and only the texel a top face wears changes. Of 3088 flat-topped runs, the 1336 rim-over-body ones change and nothing else does. tests/flat_top_test.lua walks every map and fails if any rim-over-body run revisits an earlier drawn row. --- lib/ChunkMesher.lua | 23 +++++++++- lib/Structures.lua | 38 +++++++++++++++++ tests/flat_top_test.lua | 95 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/flat_top_test.lua diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..e9b4c39 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -64,6 +64,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 @@ -528,8 +548,7 @@ 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 diff --git a/lib/Structures.lua b/lib/Structures.lua index 2c99c8d..8950380 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2225,6 +2225,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 @@ -2272,6 +2309,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 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 From d6a9f103527c28079e7ddd61b8a393013a60e2d2 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 16:27:30 -0400 Subject: [PATCH 2/7] x Removed restriction on redistribution of non-derivative code. --- README.md | 2 -- 1 file changed, 2 deletions(-) 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). From 9ddb940bc8634c933ba1c19f9533591a93b33168 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 18:51:01 -0400 Subject: [PATCH 3/7] update 2d shiny sprites and shiny animation --- data/voxel_heights.lua | 80 ++++++++- lib/ChunkMesher.lua | 35 +++- lib/OverworldBattle.lua | 62 +++---- lib/ShinyFlash.lua | 323 ++++++++++++++++++++++++++++++++++ lib/ShinyPalette.lua | 125 ++++++++++++- lib/ShinyPics.lua | 156 ++++++++++++++++ lib/ShinyUI.lua | 67 +++---- lib/Structures.lua | 89 +++++++++- lib/TileShape.lua | 45 +++++ main.lua | 27 ++- tests/shiny_flat.lua | 119 +++++++++++++ tools/shiny_palette_sheet.lua | 188 ++++++++++++++++++++ tools/shiny_pic_dump.lua | 75 ++++++++ tools/shiny_pic_sheet.py | 155 ++++++++++++++++ 14 files changed, 1448 insertions(+), 98 deletions(-) create mode 100644 lib/ShinyFlash.lua create mode 100644 lib/ShinyPics.lua create mode 100644 tests/shiny_flat.lua create mode 100644 tools/shiny_palette_sheet.lua create mode 100644 tools/shiny_pic_dump.lua create mode 100644 tools/shiny_pic_sheet.py diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 6af0a03..a963ab3 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -906,11 +906,13 @@ return { -- 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 + -- 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 +1037,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, diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 175c648..4167656 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -423,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 } }, @@ -585,6 +589,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) 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; @@ -611,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 @@ -629,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 cda30a4..2f6ee1b 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3089,6 +3089,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. @@ -3171,7 +3245,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..c4c372f 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -469,6 +469,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 +490,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 +569,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/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/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() From 34e7da5c12c5b93b4a0c37375a07866903c1e9c1 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 19:01:53 -0400 Subject: [PATCH 4/7] v-grid toggle on battles --- lib/BattleScene.lua | 12 ++++-------- lib/VoxelGrid.lua | 18 ++++++------------ tests/dramatic_shape_test.lua | 29 ++++++++++++----------------- 3 files changed, 22 insertions(+), 37 deletions(-) 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/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/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 -- From 99de8c2760cbce5f8829cc30f75793b78d08dc92 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 19:55:32 -0400 Subject: [PATCH 5/7] update 2d shiny sprites and shiny animation --- CHANGELOG.md | 44 +++++++ data/voxel_heights.lua | 60 ++++++++- lib/Buildings.lua | 280 ++++++++++++++++++++++++++++++++++++++++- lib/Structures.lua | 89 ++++++++++++- lib/TileShape.lua | 7 ++ lib/Voxel3D.lua | 17 ++- 6 files changed, 485 insertions(+), 12 deletions(-) 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/data/voxel_heights.lua b/data/voxel_heights.lua index a963ab3..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,7 +904,30 @@ 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 }, + 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 @@ -3072,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/Buildings.lua b/lib/Buildings.lua index f11d1ca..46f89a8 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,76 @@ 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) + local defs = _G.Game and Game.data and Game.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 +1697,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 +1725,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 +1743,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/Structures.lua b/lib/Structures.lua index 2f6ee1b..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) diff --git a/lib/TileShape.lua b/lib/TileShape.lua index c4c372f..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 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") From c6811674755d3c309d7396cefd11a34d42048055 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 19:57:22 -0400 Subject: [PATCH 6/7] add doors to s/w/e sides of gate houses --- lib/Buildings.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 46f89a8..d90971f 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -1633,7 +1633,13 @@ end -- 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) - local defs = _G.Game and Game.data and Game.data.maps + -- 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 From 787d93a0e4d3b9cbe2dbfc669963ed4b9194127d Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 19:57:49 -0400 Subject: [PATCH 7/7] iterate version --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",