mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:10:51 +02:00
shiny Pokemon, on by default
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to decide it -- and the engine already ships that reading (Stats.isShiny, its own comment calling it "the RBY virtual shiny", allowlisted for mods precisely so an indicator mod can call it). Nothing new is stored on a Pokemon and nothing migrates: every save already contains the answer, and this starts drawing it. Random DVs land on the pattern 1 in 8192, which is the classic rate and the default the odds dial ships at. Deriving rather than storing is what makes it survive a save, a box, a trade and an evolution with no second copy of the truth to drift. mon.shiny is a cache written from the DVs, never read as the source. The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is built there, and it is before the battle bakes its sprite, which battle.started is already too late for. It draws from the mod's own random stream so installing this does not shift the sequence damage rolls and encounter slots come out of. Trainers stay ordinary by themselves: the engine pins their DVs, as the real games do. The models are genuinely recoloured, as part of the extraction. Each species is decoded once, packed as usual, then recoloured and packed again as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees, saturation and lightness on a -8..+8 scale at 12.5% a step); five species carry an explicit colour table instead, because Stadium gives them a real alternate texture that no single slide reproduces -- Jigglypuff's body must stay pink while its irises rotate to green. Extraction is the right moment because StadiumFx's generated frames are still marked there and the packer drops the marker: it is the last point a flame is distinguishable from a hide. A shiny Charizard has a shiny hide and an ordinary fire. The normal packs are written BEFORE the recolour, so they come out byte-identical and stadium_extract_test still diffs all 151 against the Python oracle unchanged -- no format change, no DSM4, no second implementation to keep in step. REV goes to 3 so an existing cache rebuilds. Flat art is tinted instead, because the engine bakes a species palette into a cache with no notion of which individual is drawn. The tint comes from that species' own slide rather than a generic gold. A multiply can only darken, so species whose shiny is lighter read quieter there than on the model; the status page's star is the mode-proof mark. Tests: 58 assertions in tests/shiny_test.lua, including the colour transform against 640 real colour pairs lifted from the verified texture set, the DV model, the read side, and the end-to-end through the engine's own constructor. stadium_extract_test gains --mod (worktrees have neither the ROM nor the packs, both gitignored) and now also checks that every shiny pack is the same length as its twin and actually differs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
-- Where a shiny SHOWS on the flat art: the battle pics and the status page.
|
||||
--
|
||||
-- The Stadium models carry genuinely recoloured texels (see ShinyPalette and
|
||||
-- the extraction). Everything drawn as a Game Boy pic cannot, because the
|
||||
-- engine bakes a species palette into an image cache keyed by path and
|
||||
-- palette name -- a cache with no notion of WHICH Rattata is being drawn. So
|
||||
-- the flat side is answered two ways:
|
||||
--
|
||||
-- the battle pic tinted at draw time, per side, with the multiply
|
||||
-- ShinyPalette.tintFor derives from that species' own
|
||||
-- shiny slide. Under ADVANCED (`redpp`, the pokered-gbc
|
||||
-- colour pack) the pic is at its most colourful and the
|
||||
-- tint reads clearly; under the DMG modes there is
|
||||
-- barely any colour to shift, which is why the status
|
||||
-- page also carries a plain, mode-proof MARK.
|
||||
-- the status page a star beside the level, drawn in the engine's own
|
||||
-- GB pixel grid so it is palette-processed like every
|
||||
-- other pixel on the screen rather than floating over
|
||||
-- the finished frame.
|
||||
--
|
||||
-- Both are monkeypatches, idempotent by sentinel, the pattern the rest of
|
||||
-- this mod uses (OverworldBattle.install, Stadium.install). The summary
|
||||
-- screen has no hook at all -- there is no `ui.summary.*` anywhere in the
|
||||
-- engine -- so a wrap is the only route to it, and it is deliberately a thin
|
||||
-- one: draw the engine's screen, then add one glyph.
|
||||
|
||||
-- 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 ShinyUI = {}
|
||||
|
||||
-- ------- the tint, applied to one draw
|
||||
--
|
||||
-- Lifted in shape from OverworldBattle.withTint, and for the same reason it
|
||||
-- exists there: the pics layer sets its own colour many times over as it
|
||||
-- draws (the faint slide's fade, the damage blink), so the way to tint the
|
||||
-- result without clobbering any of that is to multiply every colour it sets
|
||||
-- on its way past. Restored unconditionally, including on error, because a
|
||||
-- leaked setColor would tint the entire rest of the frame.
|
||||
function ShinyUI.withTint(tint, fn, ...)
|
||||
if not tint then return fn(...) end
|
||||
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
|
||||
local gfx = love.graphics
|
||||
local setColor = gfx.setColor
|
||||
gfx.setColor = function(cr, cg, cb, ca, ...)
|
||||
if type(cr) == "table" then
|
||||
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
|
||||
cr[4] }, cg, ...)
|
||||
end
|
||||
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
|
||||
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
|
||||
end
|
||||
local ok, err = pcall(fn, ...)
|
||||
gfx.setColor = setColor
|
||||
setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- The tint for a mon, or nil when it is not shiny or we have no colours.
|
||||
function ShinyUI.tintFor(mon, data)
|
||||
if not (mon and Shiny.isShiny(mon)) then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return nil end
|
||||
return ShinyPalette.tintFor(dex)
|
||||
end
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Drawn as rectangles rather than as a font character because the Game Boy
|
||||
-- charmap has no star, and a letter would read as a typo. Four spokes and a
|
||||
-- centre, in the same near-black the screen's text uses, so the palette pass
|
||||
-- treats it exactly like a glyph -- under ADVANCED and under every DMG mode
|
||||
-- alike.
|
||||
--
|
||||
-- Seven pixels square, which is the largest that fits the gap beside the
|
||||
-- level without touching the DrawLineBox bracket at column 19.
|
||||
function ShinyUI.drawStar(px, py)
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
g.setColor(0, 0, 0, 1)
|
||||
-- vertical, horizontal, then the four diagonal nubs
|
||||
g.rectangle("fill", px + 3, py, 1, 7)
|
||||
g.rectangle("fill", px, py + 3, 7, 1)
|
||||
g.rectangle("fill", px + 1, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 1, py + 5, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 5, 1, 1)
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
|
||||
function ShinyUI.install()
|
||||
ShinyUI.installSummary()
|
||||
ShinyUI.installBattlePics()
|
||||
end
|
||||
|
||||
-- The status page. Wraps the draw and adds the star afterwards, so the
|
||||
-- engine's own layout is untouched and a layout change upstream costs us
|
||||
-- the glyph's position and nothing else.
|
||||
function ShinyUI.installSummary()
|
||||
local ok, SummaryMenu = pcall(require, "src.ui.SummaryMenu")
|
||||
if not ok or type(SummaryMenu) ~= "table" then return end
|
||||
if SummaryMenu.dramaticShapeShiny then return end
|
||||
local inner = SummaryMenu.draw
|
||||
if type(inner) ~= "function" then return end
|
||||
|
||||
function SummaryMenu:draw(...)
|
||||
local out = { inner(self, ...) }
|
||||
-- page 1 only: page 2 wipes the block the level sits in
|
||||
-- (status_screen.asm ClearScreenArea over (9,2)), so a mark left there
|
||||
-- would be half-erased by the engine's own clear.
|
||||
if self.page == 1 and Shiny.isShiny(self.mon) then
|
||||
-- beside PrintLevel at (14,2): column 13, row 2, in the gap the
|
||||
-- level's own leading space leaves
|
||||
pcall(ShinyUI.drawStar, 104, 17)
|
||||
end
|
||||
return unpack(out)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
return ShinyUI
|
||||
Reference in New Issue
Block a user