mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 06:11:10 +02:00
Merge branch 'worktree-shiny-system' into shadow-toggle
Shiny Pokemon: derived from DVs via the engine's own Stats.isShiny, models recoloured as part of the Stadium extraction, flat art tinted, a sparkle on arrival and a star on the status page. Verified on the merged tree rather than only on the branch, since shadow-toggle moved underneath it (the new menus work): the mod loads clean, tests/shiny_test passes 49, and the extraction still reproduces all 151 packs byte-for-byte against the Python oracle with 151 shiny variants alongside.
This commit is contained in:
@@ -1,5 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **SHINY POKEMON.** On by default, with no row to switch it off:
|
||||
shininess is a property of a Pokemon, not a display mode, and one that
|
||||
differed between two players' saves would be a setting rather than a
|
||||
Pokemon.
|
||||
|
||||
**It was always there.** Gen 1 has no shininess of its own, but it has
|
||||
the four DVs Gen 2 later reads to decide it, and the engine already
|
||||
ships that reading (`src/pokemon/Stats.lua`, `isShiny` -- its own
|
||||
comment calls it "the RBY virtual shiny"). So nothing new is stored on
|
||||
a Pokemon and nothing migrates: every save ever made already contains
|
||||
the answer, and this release starts drawing it. A mon is shiny when
|
||||
Defense, Speed and Special are all exactly 10 and Attack is one of
|
||||
2/3/6/7/10/11/14/15 -- which random DVs land on 1 time in 8192, 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 without a second copy of the truth to drift out of step; a
|
||||
shiny Bulbasaur is a shiny Venusaur without being told. `mon.shiny` is
|
||||
maintained as a cache beside it, written from the DVs and never read
|
||||
as the source.
|
||||
|
||||
The roll happens in `Pokemon.new`, which is where every wild, gift,
|
||||
starter and traded mon is built -- before the battle bakes its sprite,
|
||||
which `battle.started` is already too late for. It draws from the mod's
|
||||
OWN random stream rather than the game's, so installing this does not
|
||||
shift the sequence every damage roll and encounter slot comes out of.
|
||||
Trainers' Pokemon come out ordinary by themselves, because the engine
|
||||
pins their DVs to a fixed set -- which is also what the real games do.
|
||||
|
||||
**The models are genuinely recoloured**, not tinted. The recolour runs
|
||||
as part of the Stadium extraction: each species' textures are decoded
|
||||
once, packed as usual, then recoloured and packed again beside it as
|
||||
`NNNs.dsm`. The colours are Stadium's own -- it slides a model in HSL
|
||||
rather than shipping second textures, a hue rotation in degrees plus
|
||||
saturation and lightness on a quantized -8..+8 scale at 12.5% a step --
|
||||
and all 151 sets of values are shipped in `data/shiny_colors.lua`.
|
||||
Five species get an explicit colour table instead, because Stadium
|
||||
gives THEM a real alternate texture and no single slide can reproduce
|
||||
it: Clefairy, Clefable, Jigglypuff, Wigglytuff and Gyarados, whose
|
||||
bodies must stay put while a small region rotates a long way.
|
||||
Generated effect frames -- flames, beams, sparks -- are excluded, so a
|
||||
shiny Charizard has a shiny hide and an ordinary fire.
|
||||
|
||||
Doing this at extraction rather than at load is what makes that
|
||||
exclusion exact: `StadiumFx` marks its generated frames and the packer
|
||||
drops the marker, so extraction is the last moment a flame is
|
||||
distinguishable from a hide. The normal packs come out byte-identical
|
||||
either way -- the shiny pass runs after they are written -- so
|
||||
`tests/stadium_extract_test.lua` still diffs all 151 against the Python
|
||||
oracle unchanged, and the format did not move. The install marker's REV
|
||||
goes to 3 so an existing cache rebuilds rather than quietly showing
|
||||
every shiny in its ordinary colours.
|
||||
|
||||
**Flat art is tinted** rather than recoloured, because the engine bakes
|
||||
a species palette into an image cache that has no idea which individual
|
||||
is being drawn. The tint is derived from that species' own shiny slide,
|
||||
so a shiny Golbat leans green and a shiny Charizard goes dusky. On the
|
||||
3D path each side is tinted separately, which is the only place the two
|
||||
sides can differ. A multiply can only darken, so species whose shiny is
|
||||
LIGHTER than their normal read quieter on the flat art than on the
|
||||
model.
|
||||
|
||||
**A sparkle** on arrival: a ring of additive stars that springs from
|
||||
the mon's chest and fades over three quarters of a second, armed on the
|
||||
frame a side's occupant changes -- which covers a send-out, a switch
|
||||
and a wild foe alike. A wild Pokemon never grows out of a ball, so the
|
||||
grow was the wrong edge to hang it on.
|
||||
|
||||
**A star on the status page**, beside the level on page 1, drawn in the
|
||||
engine's own pixel grid so it is palette-processed like every other
|
||||
pixel rather than floating over the finished frame. Page 1 only: page 2
|
||||
clears that block itself.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
### Added
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -729,6 +729,12 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- a card wins the depth test the way a nearer thing should
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
|
||||
-- and a shiny's arrival sparkle, last of the three so its stars add
|
||||
-- over the mon they belong to rather than under it, and still inside
|
||||
-- the flash window so a burst during a hit is lit like everything else
|
||||
pcall(function()
|
||||
V.require("ShinyFx").draw(arena, groundY, BattleBillboard.PULL)
|
||||
end)
|
||||
if flashing then Voxel3D.flatten(nil) end
|
||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||
-- gives them, measured against THIS camera's pitch rather than the
|
||||
|
||||
+33
-1
@@ -598,6 +598,11 @@ end
|
||||
function OverworldBattle.update(dt)
|
||||
if not session then return end
|
||||
|
||||
-- the shiny arrival sparkle's clock. Ticked here rather than in the draw
|
||||
-- because a paused or covered frame still draws, and a burst that
|
||||
-- advanced on draws would stall behind a text box mid-twinkle.
|
||||
pcall(function() V.require("ShinyFx").update(dt) end)
|
||||
|
||||
local g = game()
|
||||
local top = g and g.stack and g.stack:top()
|
||||
local ow = g and g.overworld
|
||||
@@ -1014,6 +1019,16 @@ OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
|
||||
-- Which side is being rendered, or nil. The placement wrappers read it.
|
||||
local texturing = nil
|
||||
|
||||
-- Which side is being rendered into its own canvas right now, or nil.
|
||||
--
|
||||
-- Exposed because the shiny tint has two applications -- per side here, and
|
||||
-- both-sides-at-once on the flat path (ShinyUI.installBattlePics) -- and
|
||||
-- exactly one of them must run per draw. Asking this is what keeps them
|
||||
-- from stacking, rather than relying on which module installed first.
|
||||
function OverworldBattle.texturingSide()
|
||||
return texturing
|
||||
end
|
||||
|
||||
local texCanvas = {}
|
||||
local innerPics = nil -- captured by install()
|
||||
local innerHUDs = nil -- likewise, for the snapped HUD layer
|
||||
@@ -1086,12 +1101,29 @@ 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
|
||||
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
|
||||
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
innerPics(battle, 0, 0, 0)
|
||||
if shinyTint then
|
||||
V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
|
||||
else
|
||||
innerPics(battle, 0, 0, 0)
|
||||
end
|
||||
end)
|
||||
|
||||
texturing = nil
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
-- Shiny Pokemon: the one fact, and everywhere that asks it.
|
||||
--
|
||||
-- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no
|
||||
-- shininess of its own, but it does have the four DVs Gen 2 later read to
|
||||
-- decide it, and the engine already ships that reading:
|
||||
-- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all
|
||||
-- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment
|
||||
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
||||
-- This is that mod.
|
||||
--
|
||||
-- Deriving rather than storing is the whole design, and it buys a great
|
||||
-- deal:
|
||||
--
|
||||
-- * It persists for free. DVs are already in every save, every PC box,
|
||||
-- every trade. No new save field, no migration, and a save made before
|
||||
-- this mod was installed already HAS shiny Pokemon in it -- they were
|
||||
-- always there, nothing was ever drawn differently.
|
||||
-- * It survives evolution. Evolution.apply recalculates stats from the
|
||||
-- same dvs table and never touches it (src/pokemon/Evolution.lua:99),
|
||||
-- so a shiny Bulbasaur is a shiny Venusaur without being told.
|
||||
-- * It cannot desync. A flag stored beside the DVs is a second copy of
|
||||
-- the truth, and two copies drift -- most cruelly across a trade or a
|
||||
-- box deposit, where the mon travels and the sidecar does not.
|
||||
-- * PKHeX and the Gen 2 games agree with us, because it is their rule.
|
||||
--
|
||||
-- The odds, though, are ours to set, and that is the one thing DVs alone
|
||||
-- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 =
|
||||
-- exactly 1/8192, the classic rate, and there is no dial on it. So the roll
|
||||
-- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs
|
||||
-- (forceShiny/forceCommon below). The mon does not carry a flag saying it
|
||||
-- is shiny; it is made genuinely shiny by the game's own formula, and every
|
||||
-- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the
|
||||
-- same answer without knowing we were involved.
|
||||
--
|
||||
-- mon.shiny is maintained too, but it is a CACHE and never the source: see
|
||||
-- Shiny.mark.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
-- allowlisted for mods by name -- src/mods/Loader.lua:71 lists
|
||||
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local Shiny = {}
|
||||
|
||||
-- ------- the odds
|
||||
--
|
||||
-- One in ODDS_DENOM. The default is 8192 because that is what random DVs
|
||||
-- already produce, so a player who never changes it gets the canonical rate
|
||||
-- and the canonical feel -- this mod's default is not a buff.
|
||||
--
|
||||
-- The roll is made EXACT rather than additive. A naive implementation rolls
|
||||
-- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a
|
||||
-- miss, so the true rate is N and 8192 in parallel -- indistinguishable at
|
||||
-- the default and quietly wrong at every other setting (at 1/100 you would
|
||||
-- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no
|
||||
-- matter what you set). forceCommon on a miss closes that: the rate is what
|
||||
-- the number says.
|
||||
Shiny.ODDS_DENOM = 8192
|
||||
|
||||
-- Set the denominator. Guards the degenerate values because a 0 or a
|
||||
-- negative here would divide-by-zero or make every encounter shiny by
|
||||
-- accident rather than by choice; 1 (always shiny) stays reachable because
|
||||
-- it is genuinely useful for walking the whole model set.
|
||||
function Shiny.setOdds(denom)
|
||||
denom = tonumber(denom)
|
||||
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
|
||||
Shiny.ODDS_DENOM = math.floor(denom)
|
||||
return Shiny.ODDS_DENOM
|
||||
end
|
||||
|
||||
-- ------- reading it
|
||||
|
||||
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
|
||||
-- list as well as the engine's set because forceShiny has to CHOOSE one and
|
||||
-- wants the nearest, not just any.
|
||||
local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 }
|
||||
|
||||
-- The HP DV is not free: Gen 1 derives it from the low bit of each of the
|
||||
-- other four (src/pokemon/Stats.lua:19). Any write to the four must
|
||||
-- recompute it, or the mon ends up with an HP stat the real game could
|
||||
-- never produce -- which is exactly what a save inspector flags as illegal.
|
||||
local function syncHpDv(dvs)
|
||||
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
|
||||
(dvs.speed % 2) * 2 + (dvs.special % 2)
|
||||
return dvs
|
||||
end
|
||||
|
||||
-- The single question. Everything visual in this mod routes here.
|
||||
function Shiny.isShiny(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
return Stats.isShiny(mon.dvs) == true
|
||||
end
|
||||
|
||||
-- ------- writing it
|
||||
|
||||
-- Make these DVs satisfy the pattern, moving them as little as it allows.
|
||||
--
|
||||
-- Defense, Speed and Special have exactly one legal value each, so they are
|
||||
-- simply pinned. Attack has eight, and the nearest one to whatever was
|
||||
-- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0
|
||||
-- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter
|
||||
-- should not also be a stat reroll any larger than the pattern demands.
|
||||
local function forceShiny(dvs)
|
||||
local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil
|
||||
for _, v in ipairs(SHINY_ATK) do
|
||||
local d = math.abs(v - want)
|
||||
if not bestd or d < bestd then bestd, best = d, v end
|
||||
end
|
||||
dvs.attack = best
|
||||
dvs.defense, dvs.speed, dvs.special = 10, 10, 10
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- Make these DVs NOT satisfy the pattern, moving them as little as
|
||||
-- possible: one step on Special is enough to break it, and Special is the
|
||||
-- choice because in Gen 1 it is a single stat rather than the two Gen 2
|
||||
-- split it into, so the disturbance stays inside one number.
|
||||
--
|
||||
-- Only ever reached by a mon that rolled non-shiny and happened to be shiny
|
||||
-- by luck, which is 1/8192 of the time -- so this touches almost nothing,
|
||||
-- and what it does touch it moves by one point.
|
||||
local function forceCommon(dvs)
|
||||
if (dvs.special or 0) == 10 then
|
||||
dvs.special = 9
|
||||
elseif (dvs.defense or 0) == 10 then
|
||||
dvs.defense = 9
|
||||
end
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- mon.shiny: the cache.
|
||||
--
|
||||
-- The requirement is a flag ON the Pokemon, and this is it -- but it is
|
||||
-- written from the DVs every time we touch a mon, never read as the truth.
|
||||
-- Keeping it one-directional is what stops it becoming the second copy the
|
||||
-- header warns about: if it ever disagrees with the DVs, the DVs win and
|
||||
-- this is overwritten. It exists so other code -- and a save inspector, and
|
||||
-- a companion mod -- can ask the cheap question without importing Stats.
|
||||
function Shiny.mark(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
local is = Shiny.isShiny(mon)
|
||||
mon.shiny = is or nil -- nil rather than false: absent keeps saves clean
|
||||
return is
|
||||
end
|
||||
|
||||
-- Recalculate the stats a DV write invalidated.
|
||||
--
|
||||
-- Split out because both decide() and set() move DVs, and a mon left
|
||||
-- carrying stats computed from its old DVs is wrong in the only way the
|
||||
-- player can actually see: its HP bar.
|
||||
local function restat(mon)
|
||||
if not (mon.level and mon.species) then return end
|
||||
local ok, data = pcall(require, "src.core.Data")
|
||||
local def = ok and data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return end
|
||||
local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0)
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
-- A wild mon appears at full health, and a mon that WAS full stays full:
|
||||
-- recomputing max HP without following it here would put a freshly
|
||||
-- encountered mon on the field at less than full from its first frame.
|
||||
-- A wounded mon keeps its damage, clamped to the new maximum.
|
||||
if mon.hp then
|
||||
mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- our own randomness
|
||||
--
|
||||
-- A PRIVATE stream, not love.math.random, and that is deliberate.
|
||||
--
|
||||
-- The game's RNG is a shared sequence: damage rolls, crits, encounter
|
||||
-- slots and DV generation all draw from it in a fixed order. Taking a draw
|
||||
-- out of it for a shiny check would shift every later draw, so installing
|
||||
-- this mod would quietly change the outcome of fights that have nothing to
|
||||
-- do with shininess -- and the manifest promises `affects_link = false`,
|
||||
-- which a shifted stream would make untrue the moment two machines
|
||||
-- disagreed about whose turn consumed what.
|
||||
--
|
||||
-- Seeded off the clock rather than the save, because shininess is a fact
|
||||
-- about the encounter and not about the file: re-loading a save to re-roll
|
||||
-- a Pokemon is the hunt, and a stream keyed to the save would hand back the
|
||||
-- same answer every time.
|
||||
local stream = nil
|
||||
|
||||
local function roll(n)
|
||||
if not stream then
|
||||
if love and love.math and love.math.newRandomGenerator then
|
||||
stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6)
|
||||
else
|
||||
-- headless (tests): math.random is nobody's shared sequence there
|
||||
stream = { random = function(_, a, b) return math.random(a, b) end }
|
||||
end
|
||||
end
|
||||
return stream:random(1, n)
|
||||
end
|
||||
|
||||
-- Decide a freshly-built mon, in place.
|
||||
--
|
||||
-- rng may be passed to pin the verdict -- a test hands us a stub. Left nil,
|
||||
-- the private stream above is used.
|
||||
function Shiny.decide(mon, rng)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
|
||||
-- stub and the call below reads identically either way
|
||||
rng = rng or function(_lo, hi) return roll(hi) end
|
||||
local hit = rng(1, Shiny.ODDS_DENOM) == 1
|
||||
|
||||
if hit then
|
||||
forceShiny(mon.dvs)
|
||||
restat(mon)
|
||||
elseif Stats.isShiny(mon.dvs) then
|
||||
forceCommon(mon.dvs)
|
||||
restat(mon)
|
||||
end
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
-- Force a specific verdict: for tests, and for a scripted gift mon that
|
||||
-- wants to be shiny on purpose.
|
||||
function Shiny.set(mon, on)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end
|
||||
restat(mon)
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
return Shiny
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Where shininess enters the game, and where it is shown.
|
||||
--
|
||||
-- ------- one seam decides it
|
||||
--
|
||||
-- Every Pokemon the player can ever own is built by Pokemon.new
|
||||
-- (src/pokemon/Pokemon.lua:60). There are five callers and they are the
|
||||
-- whole surface:
|
||||
--
|
||||
-- BattleState.lua:569 the wild encounter
|
||||
-- BattleState.lua:659 a trainer's party
|
||||
-- BattleState.lua:785 the level-5 stand-in the Oak battle builds
|
||||
-- Commands.lua:656 a gift or a starter
|
||||
-- Commands.lua:969 an in-game trade
|
||||
--
|
||||
-- So the roll goes THERE rather than on the encounter hooks. Two reasons,
|
||||
-- and the second is decisive:
|
||||
--
|
||||
-- * encounter.roll and encounter.species fire before the mon exists --
|
||||
-- they carry {species, level} and nothing to write a verdict onto.
|
||||
-- * makeBattler bakes mon.sprite INSIDE newWild
|
||||
-- (src/battle/BattleState.lua:455-461), before battle.started is
|
||||
-- emitted. A verdict applied at battle.started is already too late for
|
||||
-- the sprite the fight will draw.
|
||||
--
|
||||
-- Wrapping the constructor puts the decision before every one of those, and
|
||||
-- picks up gift mons, starters and trades for free rather than needing a
|
||||
-- seam each.
|
||||
--
|
||||
-- TRAINER MONS COME OUT NON-SHINY BY THEMSELVES, and correctly so. The
|
||||
-- engine overwrites every trainer slot's DVs with a fixed TRAINER_DVS
|
||||
-- (src/battle/BattleState.lua:350, :661) right after construction, and that
|
||||
-- constant fails the shiny pattern. So the roll is made and then discarded
|
||||
-- for them -- which matches the real games, where a trainer's Pokemon is
|
||||
-- never shiny.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyBattle = {}
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses
|
||||
-- (OverworldBattle.install, Stadium.install): a hot reload must not stack a
|
||||
-- second copy of the wrapper on top of the first.
|
||||
function ShinyBattle.install()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if not Pokemon.dramaticShapeShiny then
|
||||
local inner = Pokemon.new
|
||||
function Pokemon.new(data, species, level, rng)
|
||||
local mon = inner(data, species, level, rng)
|
||||
-- pcall: a mon that fails to be decided is an ordinary mon, which is
|
||||
-- a blemish. A mon that fails to be BUILT is a broken game.
|
||||
pcall(Shiny.decide, mon)
|
||||
return mon
|
||||
end
|
||||
Pokemon.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- Party mons that predate the mod, and any mon built by a path we have
|
||||
-- not wrapped, still answer isShiny correctly -- their DVs were always
|
||||
-- there. This only refreshes the mon.shiny cache so a save opened for the
|
||||
-- first time under this mod has the field populated rather than absent
|
||||
-- until the mon next changes.
|
||||
ShinyBattle.markParty()
|
||||
end
|
||||
|
||||
-- Refresh the cached flag across the player's party.
|
||||
function ShinyBattle.markParty()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not ok then return end
|
||||
local party = Game and Game.save and Game.save.party
|
||||
if type(party) ~= "table" then return end
|
||||
for _, mon in ipairs(party) do
|
||||
pcall(Shiny.mark, mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- asking about a battler
|
||||
--
|
||||
-- The battler wrapper carries the save-shaped mon on `.mon`
|
||||
-- (src/battle/BattleState.lua:432-463), so the question is always about
|
||||
-- that table and never about the wrapper.
|
||||
function ShinyBattle.battlerIsShiny(battler)
|
||||
return battler ~= nil and Shiny.isShiny(battler.mon)
|
||||
end
|
||||
|
||||
-- Which side of a battle, by the engine's own side names.
|
||||
function ShinyBattle.sideIsShiny(battle, side)
|
||||
if not battle then return false end
|
||||
return ShinyBattle.battlerIsShiny(side == "player" and battle.player
|
||||
or battle.enemy)
|
||||
end
|
||||
|
||||
return ShinyBattle
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
-- The shiny sparkle: the flash a Pokemon makes when it first appears.
|
||||
--
|
||||
-- The games announce a shiny with a burst of stars over the sprite the
|
||||
-- instant it lands, before the first text box. This is that moment, in the
|
||||
-- diorama: a ring of additive stars that springs outward from the mon's
|
||||
-- chest, rises, and fades over about three quarters of a second.
|
||||
--
|
||||
-- ------- where the moment IS
|
||||
--
|
||||
-- Harder than it sounds, because the two battle paths arrive differently:
|
||||
--
|
||||
-- the model rung a Pokemon grows out of its ball -- Stadium.update
|
||||
-- already finds that frame (the POOF_ANIM edge) and
|
||||
-- calls StadiumMon:beginGrow.
|
||||
-- the pic rung a WILD foe is simply THERE on the first frame, with
|
||||
-- no poof and no grow at all. There is no animation to
|
||||
-- hang off.
|
||||
--
|
||||
-- So the arming edge is neither of those: it is the frame a side's OCCUPANT
|
||||
-- changes (Stadium's `session.at[side] ~= battler` test, the same identity
|
||||
-- the mode already uses because a trainer leading with two Rattata changes
|
||||
-- occupant without changing species). That edge fires for a send-out, a
|
||||
-- switch and a wild foe alike, which is exactly the set of moments a shiny
|
||||
-- should announce itself.
|
||||
--
|
||||
-- ------- drawn additively, and why it survives the flash
|
||||
--
|
||||
-- Stars are light, so they add rather than cover: `Voxel3D.blend("add")`,
|
||||
-- the same treatment the Poke Ball's glow gets. They are drawn inside the
|
||||
-- battle's flash window alongside the cards and models, so a sparkle during
|
||||
-- a hit flash is lit by it like everything else rather than floating over
|
||||
-- it as a separate layer.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Mat4 = V.require("Mat4")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
|
||||
local ShinyFx = {}
|
||||
|
||||
-- ------- shape and timing
|
||||
|
||||
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
|
||||
ShinyFx.STARS = 10 -- around the ring
|
||||
ShinyFx.RISE = 10 -- world units the ring climbs over its life
|
||||
ShinyFx.SPREAD = 7 -- how far out the ring opens
|
||||
ShinyFx.CHEST = 9 -- height above the tile the burst starts at
|
||||
ShinyFx.SIZE = 3.2 -- a star's world size at full brightness
|
||||
|
||||
-- one per side, nil when nothing is playing
|
||||
local live = { player = nil, enemy = nil }
|
||||
local star = nil -- the generated star image, built once
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Generated rather than shipped: it is a four-pointed twinkle, which is a
|
||||
-- cheap closed form (a radial falloff times a cross-shaped spike term) and
|
||||
-- costs nothing next to an asset that would have to be authored, packed,
|
||||
-- loaded and kept in step with the rest of the mod's art.
|
||||
local function starImage()
|
||||
if star ~= nil then return star or nil end
|
||||
if not (love and love.image and love.graphics) then
|
||||
star = false
|
||||
return nil
|
||||
end
|
||||
local ok, img = pcall(function()
|
||||
local N = 32
|
||||
local data = love.image.newImageData(N, N)
|
||||
local c = (N - 1) / 2
|
||||
for y = 0, N - 1 do
|
||||
for x = 0, N - 1 do
|
||||
local dx, dy = (x - c) / c, (y - c) / c
|
||||
local r = math.sqrt(dx * dx + dy * dy)
|
||||
-- the body: a soft core that is gone by the edge of the square
|
||||
local core = math.max(0, 1 - r)
|
||||
core = core * core * core
|
||||
-- the spikes: bright along the two axes, narrow, and reaching
|
||||
-- further out than the core does
|
||||
local ax, ay = math.abs(dx), math.abs(dy)
|
||||
local spike = math.max(0, 1 - ax * 6) * math.max(0, 1 - ay)
|
||||
+ math.max(0, 1 - ay * 6) * math.max(0, 1 - ax)
|
||||
local a = math.min(1, core + spike * 0.55)
|
||||
-- white with the faintest warm cast, so a sparkle over a cool
|
||||
-- model still reads as light rather than as a blue smear
|
||||
data:setPixel(x, y, 1, 1, 0.97, a)
|
||||
end
|
||||
end
|
||||
return love.graphics.newImage(data)
|
||||
end)
|
||||
star = (ok and img) or false
|
||||
return star or nil
|
||||
end
|
||||
|
||||
-- ------- arming
|
||||
|
||||
-- Start (or restart) the burst on one side. Restarting rather than ignoring
|
||||
-- a second call is deliberate: a shiny that faints and is sent back out
|
||||
-- should sparkle again.
|
||||
function ShinyFx.arm(side)
|
||||
if side ~= "player" and side ~= "enemy" then return end
|
||||
live[side] = { t = 0 }
|
||||
end
|
||||
|
||||
function ShinyFx.clear(side)
|
||||
if side then live[side] = nil else live.player, live.enemy = nil, nil end
|
||||
end
|
||||
|
||||
function ShinyFx.active(side)
|
||||
if side then return live[side] ~= nil end
|
||||
return live.player ~= nil or live.enemy ~= nil
|
||||
end
|
||||
|
||||
function ShinyFx.update(dt)
|
||||
dt = dt or 0
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
if s then
|
||||
s.t = s.t + dt
|
||||
if s.t >= ShinyFx.LIFE then live[side] = nil end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
|
||||
-- Eased so the ring leaves fast and settles, which is what a spark does;
|
||||
-- linear looks like a diagram of a spark.
|
||||
local function easeOut(u) return 1 - (1 - u) * (1 - u) end
|
||||
|
||||
-- Draw whatever is playing. `arena` and `groundY` come from the scene, the
|
||||
-- same two the mon cards are placed from, so a sparkle lands where its
|
||||
-- Pokemon is standing rather than where the layout thinks it should be.
|
||||
function ShinyFx.draw(arena, groundY, pull)
|
||||
if not arena then return end
|
||||
local img = starImage()
|
||||
if not img then return end
|
||||
local mesh = BattleBillboard.mesh()
|
||||
if not mesh then return end
|
||||
|
||||
local drew = false
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
if s and cell then
|
||||
local u = math.min(1, s.t / ShinyFx.LIFE)
|
||||
local e = easeOut(u)
|
||||
-- bright immediately, then out: the announcement is the first frame
|
||||
local alpha = 1 - u * u
|
||||
local x, z = cell[1], cell[2]
|
||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
||||
local baseY = groundY + ShinyFx.CHEST + ShinyFx.RISE * e
|
||||
|
||||
if not drew then
|
||||
Voxel3D.blend("add")
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
drew = true
|
||||
end
|
||||
|
||||
for i = 1, ShinyFx.STARS do
|
||||
-- the ring is offset half a step per side so the two sides do not
|
||||
-- twinkle in lockstep when both are shiny
|
||||
local a = (i / ShinyFx.STARS) * math.pi * 2
|
||||
+ (side == "player" and 0.31 or 0)
|
||||
local r = ShinyFx.SPREAD * e
|
||||
-- stars shrink as they fade, and alternate size so the ring reads
|
||||
-- as scattered rather than as a cog
|
||||
local k = ShinyFx.SIZE * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
|
||||
local ox = math.cos(a) * r
|
||||
local oy = math.sin(a) * r * 0.55 -- flattened: we look from low
|
||||
local m = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(x, baseY, z), Mat4.rotateY(yaw)),
|
||||
Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(k, k, 1)))
|
||||
love.graphics.setColor(1, 1, 1, alpha)
|
||||
Voxel3D.draw(mesh, img, m, pull)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if drew then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.blend("alpha")
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop the generated image (hot reload, or a graphics context that went
|
||||
-- away) -- the same contract StadiumPack.invalidate honours.
|
||||
function ShinyFx.invalidate()
|
||||
if star and star.release then pcall(star.release, star) end
|
||||
star = nil
|
||||
ShinyFx.clear()
|
||||
end
|
||||
|
||||
return ShinyFx
|
||||
@@ -0,0 +1,345 @@
|
||||
-- The shiny recolour: Stadium's own HSL slide, run over decoded texels.
|
||||
--
|
||||
-- THE COLOUR MODEL IS STADIUM'S, not an invention. The Stadium games do not
|
||||
-- ship a second set of textures for a shiny Pokemon; they convert the
|
||||
-- colours the model already has to HSL and slide them -- a hue rotation in
|
||||
-- degrees, plus saturation and lightness on a quantized integer scale of
|
||||
-- -8..+8 where 0 is no change. One step is 12.5%, so +-8 is +-100%: exactly
|
||||
-- the range of GIMP's Hue-Saturation sliders, which is where the 12.5%
|
||||
-- figure was measured. s = -8 is full greyscale, l = +8 is white.
|
||||
--
|
||||
-- That equivalence is why the maths below is GIMP's Hue-Saturation and not
|
||||
-- a plain additive offset:
|
||||
--
|
||||
-- saturation s' = s * (1 + k) multiplicative
|
||||
-- lightness l' = l * (1 + k) k < 0 scale toward black
|
||||
-- l' = l + k * (1 - l) k > 0 blend toward white
|
||||
--
|
||||
-- The multiplicative saturation is the reason this is safe to run over a
|
||||
-- whole texture rather than a masked region: a pixel with no saturation --
|
||||
-- an eye white, a tooth, a grey shadow -- is immune to BOTH the hue
|
||||
-- rotation and the saturation step, for free and by construction. Only the
|
||||
-- lightness step touches achromatic pixels, which is why the species
|
||||
-- carrying big l values (Golbat and Slowpoke at -6, Moltres at +5) are the
|
||||
-- ones worth looking at with human eyes.
|
||||
--
|
||||
-- FIVE SPECIES CANNOT BE SLID. Clefairy, Clefable, Jigglypuff, Wigglytuff
|
||||
-- and Gyarados get a real alternate texture in Stadium, because their shiny
|
||||
-- moves one region a long way and leaves another alone -- Jigglypuff's body
|
||||
-- stays pink while its irises go green -- and a single rotation moves
|
||||
-- everything or nothing.
|
||||
--
|
||||
-- Those five carry `lut` instead: an explicit before/after colour mapping,
|
||||
-- sampled from the verified texture pairs, listing only the colours that
|
||||
-- actually move. A first attempt drove them from a handful of per-region
|
||||
-- anchors and picked the nearest one per pixel, which is wrong in a way
|
||||
-- worth recording: with regions as far apart as Clefairy's pink body and
|
||||
-- its green ear tips, a dark red shadow pixel is "nearest" to the green and
|
||||
-- gets rotated 150 degrees the wrong way. The fixture caught it at a
|
||||
-- 124/255 channel error. An exact table is a few tens of kilobytes and has
|
||||
-- no such failure mode, so these five are data rather than algorithm.
|
||||
--
|
||||
-- WHY THIS RUNS AT EXTRACTION. The textures are already decoded to RGBA in
|
||||
-- memory at that moment (StadiumFragment.decodeTexture), and -- the part
|
||||
-- that matters -- generated effect frames are still distinguishable there.
|
||||
-- StadiumFx marks them `generated = true`, and the packer drops that field,
|
||||
-- so at runtime an additive flame can only be inferred back from the prim
|
||||
-- table. Recolouring a flame is wrong: a shiny Charizard has a shiny hide
|
||||
-- and an ordinary fire. Doing the work while the marker still exists means
|
||||
-- the discrimination is exact rather than reconstructed.
|
||||
--
|
||||
-- THE MEMO IS WHAT MAKES IT AFFORDABLE. These are N64 textures: a few
|
||||
-- hundred distinct colours across tens of thousands of texels. Converting
|
||||
-- per DISTINCT COLOUR instead of per pixel turns the inner loop into a
|
||||
-- table lookup, which is the difference between a pass that is felt during
|
||||
-- the install and one that is not.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ShinyPalette = {}
|
||||
|
||||
local floor, min, max, abs = math.floor, math.min, math.max, math.abs
|
||||
local byte, char, concat = string.byte, string.char, table.concat
|
||||
|
||||
-- ------- HSL
|
||||
|
||||
local function rgbToHsl(r, g, b)
|
||||
r, g, b = r / 255, g / 255, b / 255
|
||||
local mx, mn = max(r, g, b), min(r, g, b)
|
||||
local l = (mx + mn) / 2
|
||||
if mx == mn then return 0, 0, l end -- achromatic: hue is undefined
|
||||
local d = mx - mn
|
||||
local s = l > 0.5 and d / (2 - mx - mn) or d / (mx + mn)
|
||||
local h
|
||||
if mx == r then
|
||||
h = (g - b) / d + (g < b and 6 or 0)
|
||||
elseif mx == g then
|
||||
h = (b - r) / d + 2
|
||||
else
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
return h * 60, s, l
|
||||
end
|
||||
|
||||
local function hue2rgb(p, q, t)
|
||||
if t < 0 then t = t + 1 end
|
||||
if t > 1 then t = t - 1 end
|
||||
if t < 1 / 6 then return p + (q - p) * 6 * t end
|
||||
if t < 1 / 2 then return q end
|
||||
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
|
||||
return p
|
||||
end
|
||||
|
||||
local function hslToRgb(h, s, l)
|
||||
if s <= 0 then
|
||||
local v = floor(l * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
h = (h % 360) / 360
|
||||
local q = l < 0.5 and l * (1 + s) or l + s - l * s
|
||||
local p = 2 * l - q
|
||||
return floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5)
|
||||
end
|
||||
|
||||
-- GIMP's two curves, shared by the slide and the anchor paths so both
|
||||
-- reach the same colour from the same k.
|
||||
local function shiftSat(s, k)
|
||||
if k == 0 then return s end
|
||||
return max(0, min(1, s * (1 + k)))
|
||||
end
|
||||
|
||||
local function shiftLight(l, k)
|
||||
if k == 0 then return l end
|
||||
if k < 0 then return max(0, l * (1 + k)) end
|
||||
return min(1, l + k * (1 - l))
|
||||
end
|
||||
|
||||
-- ------- the two kinds of transform
|
||||
|
||||
-- A whole-model slide: the 146 species Stadium recolours this way.
|
||||
local function slideFn(slide)
|
||||
local dh = slide.h or 0
|
||||
local ks = (slide.s or 0) * 0.125
|
||||
local kl = (slide.l or 0) * 0.125
|
||||
return function(r, g, b)
|
||||
local h, s, l = rgbToHsl(r, g, b)
|
||||
-- An achromatic pixel has no hue to rotate and no saturation to scale;
|
||||
-- only a lightness step can reach it. Returning early is not just
|
||||
-- speed, it is exactness: round-tripping grey through HSL and back can
|
||||
-- move it by a unit, and a tooth that drifts is a visible defect.
|
||||
if s <= 0 then
|
||||
if kl == 0 then return r, g, b end
|
||||
local v = floor(shiftLight(l, kl) * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
return hslToRgb(h + dh, shiftSat(s, ks), shiftLight(l, kl))
|
||||
end
|
||||
end
|
||||
|
||||
-- An exact colour mapping: the five species Stadium gives a real second
|
||||
-- texture. A colour absent from the table is one the alternate texture left
|
||||
-- alone, so passing it straight through is the correct answer, not a
|
||||
-- fallback -- that is how Wigglytuff keeps its white belly and its black
|
||||
-- inner ears while its body moves to lilac.
|
||||
local function lutFn(lut)
|
||||
return function(r, g, b)
|
||||
local hit = lut[r * 65536 + g * 256 + b]
|
||||
if not hit then return r, g, b end
|
||||
return floor(hit / 65536) % 256, floor(hit / 256) % 256, hit % 256
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the colour table
|
||||
--
|
||||
-- Loaded lazily and cached. Two paths on purpose: through the mod namespace
|
||||
-- when the mod is running, and straight off disk when it is not. The
|
||||
-- extraction byte-diff (tests/stadium_extract_test.lua) stubs V with only
|
||||
-- `require` and `mod.log`, and the recolour has to be exercisable under
|
||||
-- exactly that harness -- a colour transform that can only run inside a
|
||||
-- live LOVE process is a colour transform nobody will test.
|
||||
local colors = nil
|
||||
|
||||
local function loadColors()
|
||||
if colors ~= nil then return colors or nil end
|
||||
if V and V.data then
|
||||
local ok, t = pcall(V.data, "shiny_colors")
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
-- Off disk, RELATIVE TO THE MOD rather than to the working directory.
|
||||
-- V.path is the mod's own directory (main.lua sets it; the headless
|
||||
-- harnesses set it to whatever --mod they were given). Guessing from the
|
||||
-- cwd instead is what made this silently find nothing when the extraction
|
||||
-- test was run from the project root rather than from the mod: every
|
||||
-- species built, none recoloured, and a PASS at the end of it.
|
||||
local tries = {}
|
||||
if V and V.path then tries[#tries + 1] = V.path .. "/data/shiny_colors.lua" end
|
||||
tries[#tries + 1] = "data/shiny_colors.lua"
|
||||
tries[#tries + 1] = "mods/DramaticShapeVoxelMod/data/shiny_colors.lua"
|
||||
for _, p in ipairs(tries) do
|
||||
local chunk = loadfile(p)
|
||||
if chunk then
|
||||
local ok, t = pcall(chunk)
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
end
|
||||
colors = false -- cache the miss; do not retry the disk per species
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the colour table was found at all. The extraction asks so it can
|
||||
-- say "no colours" once and loudly, rather than reporting 151 successful
|
||||
-- builds with no shiny variant among them.
|
||||
function ShinyPalette.haveColors()
|
||||
return loadColors() ~= nil
|
||||
end
|
||||
|
||||
-- The spec for one dex number, or nil if we have nothing for it.
|
||||
function ShinyPalette.forDex(dex)
|
||||
local all = loadColors()
|
||||
return all and all[dex] or nil
|
||||
end
|
||||
|
||||
-- Build the pixel transform for one species' spec, or nil when there is
|
||||
-- nothing to do.
|
||||
function ShinyPalette.transform(spec)
|
||||
if type(spec) ~= "table" then return nil end
|
||||
if spec.lut then
|
||||
if next(spec.lut) == nil then return nil end
|
||||
return lutFn(spec.lut)
|
||||
end
|
||||
local s = spec.slide
|
||||
if not s then return nil end
|
||||
if (s.h or 0) == 0 and (s.l or 0) == 0 and (s.s or 0) == 0 then return nil end
|
||||
return slideFn(s)
|
||||
end
|
||||
|
||||
-- ------- the pass over one texture
|
||||
--
|
||||
-- Memoised per distinct colour (see the header). The key packs RGB into one
|
||||
-- integer because a table with 24-bit integer keys is a flat array probe,
|
||||
-- where a "r,g,b" string key would allocate on every pixel -- and allocation
|
||||
-- inside a multi-million-iteration loop is the whole cost.
|
||||
--
|
||||
-- Alpha is copied through untouched, never premultiplied and never
|
||||
-- recomputed: the transform is defined on colour alone, and a shiny
|
||||
-- Gastly's soft edge must stay exactly as soft as it was.
|
||||
function ShinyPalette.recolorTexels(rgba, fn)
|
||||
local n = #rgba
|
||||
if n == 0 or not fn then return rgba end
|
||||
local memo = {}
|
||||
local out, blocks = {}, {}
|
||||
local bi = 0
|
||||
for i = 1, n, 4 do
|
||||
local r, g, b, a = byte(rgba, i, i + 3)
|
||||
local key = r * 65536 + g * 256 + b
|
||||
local hit = memo[key]
|
||||
if not hit then
|
||||
local nr, ng, nb = fn(r, g, b)
|
||||
hit = { nr, ng, nb }
|
||||
memo[key] = hit
|
||||
end
|
||||
bi = bi + 1
|
||||
blocks[bi] = char(hit[1], hit[2], hit[3], a)
|
||||
-- flushed in blocks so the concat never walks a table with millions of
|
||||
-- one-texel strings in it
|
||||
if bi >= 4096 then
|
||||
out[#out + 1] = concat(blocks)
|
||||
blocks, bi = {}, 0
|
||||
end
|
||||
end
|
||||
if bi > 0 then out[#out + 1] = concat(blocks, "", 1, bi) end
|
||||
return concat(out)
|
||||
end
|
||||
|
||||
-- ------- a tint, for the flat sprites
|
||||
--
|
||||
-- The 3D models get real recoloured texels. The 2D battle pics cannot: the
|
||||
-- engine bakes a species palette into a cached image keyed by path and
|
||||
-- palette name, and that cache has no idea which INDIVIDUAL is being drawn.
|
||||
-- What is available per-draw is the draw colour, which multiplies.
|
||||
--
|
||||
-- So the pic is tinted, and the tint is derived from the species' OWN shiny
|
||||
-- slide rather than being a generic gold: run a spread of reference colours
|
||||
-- through the real transform, take the mean ratio out to in, and that is
|
||||
-- the multiply that best stands in for it. A shiny Golbat leans green, a
|
||||
-- shiny Charizard goes dusky, and neither is a costume.
|
||||
--
|
||||
-- ITS ONE LIMIT, stated plainly: a multiply can only darken. Where a species'
|
||||
-- shiny is LIGHTER than its normal, the honest ratio is above 1 and gets
|
||||
-- clamped, so those come out under-shifted -- present, but quieter than the
|
||||
-- model. The floor keeps the darkest cases readable rather than muddy.
|
||||
local TINT_FLOOR = 0.45
|
||||
local tintCache = {}
|
||||
|
||||
-- Mid-tone references across the wheel. Deliberately not greys: the slide
|
||||
-- is multiplicative in saturation, so a grey reference would report no
|
||||
-- change for every species and hand back a tint of 1,1,1.
|
||||
local REFS = {
|
||||
{ 200, 90, 70 }, { 200, 150, 70 }, { 190, 190, 80 }, { 90, 180, 90 },
|
||||
{ 80, 170, 170 }, { 80, 120, 200 }, { 140, 90, 190 }, { 190, 90, 150 },
|
||||
}
|
||||
|
||||
function ShinyPalette.tintFor(dex)
|
||||
local hit = tintCache[dex]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local fn = ShinyPalette.transform(ShinyPalette.forDex(dex))
|
||||
if not fn then tintCache[dex] = false; return nil end
|
||||
local sr, sg, sb, n = 0, 0, 0, 0
|
||||
for _, c in ipairs(REFS) do
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
sr = sr + r / c[1]
|
||||
sg = sg + g / c[2]
|
||||
sb = sb + b / c[3]
|
||||
n = n + 1
|
||||
end
|
||||
local t = {
|
||||
max(TINT_FLOOR, min(1, sr / n)),
|
||||
max(TINT_FLOOR, min(1, sg / n)),
|
||||
max(TINT_FLOOR, min(1, sb / n)),
|
||||
}
|
||||
-- a tint that came out as no tint at all is worse than none: it costs a
|
||||
-- colour-hook wrap per draw and changes nothing
|
||||
if t[1] > 0.995 and t[2] > 0.995 and t[3] > 0.995 then
|
||||
tintCache[dex] = false
|
||||
return nil
|
||||
end
|
||||
tintCache[dex] = t
|
||||
return t
|
||||
end
|
||||
|
||||
-- ------- the pass over one species' whole texture array
|
||||
|
||||
-- Recolour `textures` in place, skipping the ones that must not move.
|
||||
--
|
||||
-- Two exclusions, both load-bearing:
|
||||
--
|
||||
-- generated / index == -1 StadiumFx's flipbook frames -- flames, beams,
|
||||
-- sparks. A shiny Pokemon has a shiny hide and
|
||||
-- an ordinary fire; tinting the attack effects
|
||||
-- would read as a bug. This marker exists ONLY
|
||||
-- here, which is why the recolour lives at
|
||||
-- extraction (see the header).
|
||||
-- w or h of zero a degenerate slot with nothing to transform.
|
||||
--
|
||||
-- Returns the number of textures actually touched, so the caller can tell a
|
||||
-- species that recoloured from one that silently did not.
|
||||
function ShinyPalette.recolorTextures(textures, spec)
|
||||
local fn = ShinyPalette.transform(spec)
|
||||
if not fn then return 0 end
|
||||
local touched = 0
|
||||
for i = 1, #textures do
|
||||
local t = textures[i]
|
||||
local skip = t.generated == true or t.index == -1
|
||||
or not t.w or not t.h or t.w == 0 or t.h == 0
|
||||
if not skip and t.rgba and #t.rgba > 0 then
|
||||
t.rgba = ShinyPalette.recolorTexels(t.rgba, fn)
|
||||
touched = touched + 1
|
||||
end
|
||||
end
|
||||
return touched
|
||||
end
|
||||
|
||||
return ShinyPalette
|
||||
+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
|
||||
+26
-3
@@ -51,6 +51,8 @@ local V = ...
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
local StadiumMon = V.require("StadiumMon")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyFx = V.require("ShinyFx")
|
||||
|
||||
local Stadium = {}
|
||||
|
||||
@@ -370,6 +372,15 @@ function Stadium.update(dt, battle, groundY)
|
||||
-- a fresh arrival: this Pokemon has not grown out of its ball yet
|
||||
if mon then mon.grow, mon.grewOwn = nil, nil end
|
||||
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
|
||||
-- and if it is shiny, announce it. This edge rather than the grow,
|
||||
-- because a WILD foe never grows -- it is on the field from the
|
||||
-- first frame -- and that is the encounter a shiny most wants to be
|
||||
-- announced on. See the header of ShinyFx.
|
||||
if ShinyBattle.battlerIsShiny(battler) then
|
||||
ShinyFx.arm(side)
|
||||
else
|
||||
ShinyFx.clear(side)
|
||||
end
|
||||
end
|
||||
-- the collapse this side is owed, once its bar has finished emptying
|
||||
if session.faintPending and session.faintPending[side] then
|
||||
@@ -381,14 +392,26 @@ function Stadium.update(dt, battle, groundY)
|
||||
end
|
||||
end
|
||||
|
||||
mon:setSpecies(dex)
|
||||
-- Shininess is a property of the OCCUPANT, not of the species, so it is
|
||||
-- resolved here beside the dex number and passed with it. A shiny
|
||||
-- Rattata and an ordinary one are the same dex and different models.
|
||||
--
|
||||
-- Read off the battler rather than remembered, because Transform makes
|
||||
-- the two disagree: a Ditto that copied a shiny Rattata wears the
|
||||
-- Rattata's dex (session.transform above) and keeps its OWN shininess,
|
||||
-- which is exactly what the games do.
|
||||
local shiny = battler ~= nil and not session.transform[side]
|
||||
and ShinyBattle.battlerIsShiny(battler)
|
||||
|
||||
mon:setSpecies(dex, shiny)
|
||||
-- and tell the pack cache this one is standing there, every frame. Its
|
||||
-- eviction order is keyed on LOADS, and a side only loads when its
|
||||
-- species changes -- so without this a Pokemon that has been out for a
|
||||
-- few turns is the least recently loaded thing in the cache and gets its
|
||||
-- textures released out from under it the moment a fifth species enters
|
||||
-- the battle (see StadiumPack.keep).
|
||||
if mon.species then StadiumPack.keep(mon.species) end
|
||||
-- the battle (see StadiumPack.keep). The shiny flag rides along: the
|
||||
-- shiny and normal models are separate cache entries.
|
||||
if mon.species then StadiumPack.keep(mon.species, mon.shiny) end
|
||||
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
|
||||
and not (battler and battler.substituteHP)
|
||||
-- LET'S GO capture mode: the player's model is out of the shot the
|
||||
|
||||
+41
-2
@@ -26,6 +26,7 @@ local V = ...
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumFragment = V.require("StadiumFragment")
|
||||
local StadiumFx = V.require("StadiumFx")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local StadiumBuild = {}
|
||||
|
||||
@@ -661,7 +662,41 @@ function StadiumBuild.species(rom, fileno)
|
||||
local ctx = StadiumBuild.contextTable(rows, #data.anims)
|
||||
local bytes, height, floorY, radius =
|
||||
StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
return { species = species, bytes = bytes, height = height,
|
||||
|
||||
-- ------- and the shiny, from the same extraction
|
||||
--
|
||||
-- ORDER MATTERS AND IS THE WHOLE TRICK. The normal pack is written FIRST,
|
||||
-- off untouched texels, so `bytes` is bit-for-bit what it has always been
|
||||
-- and tests/stadium_extract_test.lua keeps diffing green against the
|
||||
-- Python oracle. Only then are the textures recoloured and the model
|
||||
-- packed a second time. The oracle knows nothing about shiny and does not
|
||||
-- need to: the format did not move, so there is no second implementation
|
||||
-- to keep in step and no DSM4.
|
||||
--
|
||||
-- Recolouring HERE rather than at load is what makes the effect textures
|
||||
-- separable. StadiumFx marks its generated frames `generated = true` and
|
||||
-- the packer drops the field, so this is the last moment a flame is
|
||||
-- distinguishable from a hide without inferring it back from the prim
|
||||
-- table. A shiny Charizard has a shiny hide and an ordinary fire.
|
||||
--
|
||||
-- Failure is not fatal: a species whose colours we lack, or a transform
|
||||
-- that throws, simply ships without a shiny variant and the runtime falls
|
||||
-- back to the normal model. Losing a recolour is a blemish; losing the
|
||||
-- install is a broken mod.
|
||||
local shinyBytes
|
||||
local ok, err = pcall(function()
|
||||
local spec = ShinyPalette.forDex(species)
|
||||
if not spec then return end
|
||||
if ShinyPalette.recolorTextures(data.textures, spec) == 0 then return end
|
||||
shinyBytes = StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
end)
|
||||
if not ok and V and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny recolour failed for species %d: %s",
|
||||
species, tostring(err))
|
||||
end
|
||||
|
||||
return { species = species, bytes = bytes, shinyBytes = shinyBytes,
|
||||
height = height,
|
||||
floor = floorY, radius = radius, bones = #data.bones,
|
||||
prims = #data.prims, anims = #data.anims,
|
||||
warnings = data.warnings }
|
||||
@@ -684,13 +719,17 @@ function StadiumBuild.job(rom, write, count)
|
||||
local fileno = self.done
|
||||
local ok, res, err = pcall(StadiumBuild.species, rom, fileno)
|
||||
if ok and res then
|
||||
local wrote, wErr = write(res.species, res.bytes)
|
||||
local wrote, wErr = write(res.species, res.bytes, res.shinyBytes)
|
||||
if not wrote then
|
||||
self.error = wErr or ("could not write species " .. res.species)
|
||||
self.done = self.total
|
||||
return false
|
||||
end
|
||||
self.bytes = self.bytes + #res.bytes
|
||||
if res.shinyBytes then
|
||||
self.bytes = self.bytes + #res.shinyBytes
|
||||
self.shiny = (self.shiny or 0) + 1
|
||||
end
|
||||
self.species = res.species
|
||||
else
|
||||
self.failed[#self.failed + 1] = fileno
|
||||
|
||||
+31
-2
@@ -61,7 +61,14 @@ StadiumInstall.FORMAT = "DSM3"
|
||||
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
|
||||
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
|
||||
-- any rev-1 build.
|
||||
StadiumInstall.REV = 2
|
||||
--
|
||||
-- Rev 3 adds the shiny variants (NNNs.dsm). The normal packs are unchanged
|
||||
-- byte for byte, so this is exactly the case REV exists for and not a FORMAT
|
||||
-- bump: nothing about DSM3 moved, there is simply a second file per species
|
||||
-- that a rev-2 cache does not have. Without the bump a player who already
|
||||
-- installed would keep a complete-looking cache with no shiny models in it,
|
||||
-- and every shiny they met would silently show its normal colours.
|
||||
StadiumInstall.REV = 3
|
||||
|
||||
StadiumInstall.COUNT = 151
|
||||
|
||||
@@ -211,12 +218,34 @@ local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }
|
||||
|
||||
StadiumInstall.status = status
|
||||
|
||||
local function writePack(species, bytes)
|
||||
-- The shiny variant rides beside its species as NNNs.dsm.
|
||||
--
|
||||
-- A separate FILE rather than a second block inside NNN.dsm, and that is a
|
||||
-- deliberate trade. A second block would mean a new magic (DSM4), the same
|
||||
-- change mirrored into tools/stadium_pack.py, a regenerated oracle and a
|
||||
-- re-run of the 34MB byte diff -- the project's central safety net disturbed
|
||||
-- for a feature that does not need the format to move at all. As its own
|
||||
-- file it is the SAME DSM3 a normal pack is, written by the same writer and
|
||||
-- read by the same reader, and the 151 normal packs stay byte-identical.
|
||||
--
|
||||
-- A species with no shiny variant simply has no NNNs.dsm, and StadiumPack
|
||||
-- falls back to the normal model. That is also what a half-finished install
|
||||
-- looks like, which is the behaviour we want from one.
|
||||
local function writePack(species, bytes, shinyBytes)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species),
|
||||
bytes)
|
||||
if not ok then return false, tostring(err) end
|
||||
if shinyBytes then
|
||||
-- A failed shiny write is not a failed install: the species still has
|
||||
-- its model. Left unwritten, the runtime shows the normal one.
|
||||
local sok, serr = f.write(
|
||||
("%s/%03ds.dsm"):format(StadiumInstall.DIR, species), shinyBytes)
|
||||
if not sok and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny pack %03d not written: %s", species, tostring(serr))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
+22
-3
@@ -179,6 +179,7 @@ function StadiumMon.new(side)
|
||||
return setmetatable({
|
||||
side = side, -- "player" or "enemy"
|
||||
species = nil, -- the dex number currently modelled
|
||||
shiny = false, -- and whether it is the recoloured variant
|
||||
model = nil,
|
||||
rig = nil,
|
||||
state = "idle",
|
||||
@@ -195,6 +196,9 @@ end
|
||||
function StadiumMon:release()
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, nil
|
||||
-- cleared with the species: a stale true here would make the next
|
||||
-- setSpecies believe a shiny model was already loaded and early-return
|
||||
self.shiny = false
|
||||
end
|
||||
|
||||
-- ------- which species this side is showing
|
||||
@@ -222,14 +226,29 @@ end
|
||||
-- DATA rather than a list of dex numbers, so a future extraction bug that
|
||||
-- corrupts a species' idle falls back to the sprite instead of coming
|
||||
-- apart on the field -- and nothing here has to be edited when it does.
|
||||
function StadiumMon:setSpecies(dex)
|
||||
if dex == self.species then return self.rig ~= nil end
|
||||
--
|
||||
-- `shiny` is part of the IDENTITY, not a flag applied afterwards. The early
|
||||
-- return below is keyed on it for that reason: a shiny Rattata and an
|
||||
-- ordinary one share a dex number but are different models, loaded from
|
||||
-- different packs, and comparing on the dex alone would keep whichever
|
||||
-- loaded first and colour both sides with it. That is precisely the shape
|
||||
-- of bug the two-Rattata note above describes, and it is silent -- the
|
||||
-- model is valid, it is simply the wrong one.
|
||||
function StadiumMon:setSpecies(dex, shiny)
|
||||
shiny = shiny and true or false
|
||||
if dex == self.species and shiny == (self.shiny or false) then
|
||||
return self.rig ~= nil
|
||||
end
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, dex
|
||||
self.shiny = shiny
|
||||
self.grow, self.grewOwn = nil, nil
|
||||
if not dex then return false end
|
||||
local model = StadiumPack.load(dex)
|
||||
local model = StadiumPack.load(dex, shiny)
|
||||
if not model then return false end
|
||||
-- the pack falls back to the normal model when a species has no shiny
|
||||
-- variant, so believe the model rather than the request
|
||||
self.shiny = model.shiny and true or false
|
||||
if model.staticPose then return false end
|
||||
local rig = StadiumRig.new(model)
|
||||
if not rig then return false end
|
||||
|
||||
+74
-20
@@ -62,13 +62,22 @@ local floor = math.floor
|
||||
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
|
||||
StadiumPack.DIR = "assets/stadium"
|
||||
|
||||
local function readPack(species)
|
||||
-- The shiny variant sits beside its species as NNNs.dsm -- the same DSM3,
|
||||
-- written by the same writer, differing only in its texture bytes. See the
|
||||
-- note over StadiumInstall's writePack for why it is a separate file and not
|
||||
-- a second block in the pack.
|
||||
local function packName(dir, species, shiny)
|
||||
return shiny and ("%s/%03ds.dsm"):format(dir, species)
|
||||
or ("%s/%03d.dsm"):format(dir, species)
|
||||
end
|
||||
|
||||
local function readPack(species, shiny)
|
||||
-- The cache only counts when StadiumInstall's marker says it is a
|
||||
-- complete, CURRENT build -- an old cache (a rev the extractor has since
|
||||
-- fixed, a format that moved) must not shadow a fresh shipped set, and a
|
||||
-- half-written folder must not be read at all. Required lazily: Install
|
||||
-- requires this module at load, so the reverse edge cannot be taken then.
|
||||
local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species)
|
||||
local rel = packName(StadiumPack.CACHE_DIR, species, shiny)
|
||||
if love and love.filesystem and love.filesystem.getInfo
|
||||
and V.require("StadiumInstall").ready() then
|
||||
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
|
||||
@@ -80,7 +89,7 @@ local function readPack(species)
|
||||
local mod = V.mod
|
||||
if not (mod and mod.read) then return nil end
|
||||
local ok, bytes = pcall(mod.read, mod,
|
||||
("%s/%03d.dsm"):format(StadiumPack.DIR, species))
|
||||
packName(StadiumPack.DIR, species, shiny))
|
||||
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
|
||||
return nil
|
||||
end
|
||||
@@ -474,8 +483,27 @@ end
|
||||
|
||||
-- ------- the cache
|
||||
|
||||
local cache = {} -- species -> model
|
||||
local order = {} -- species, least recently used first
|
||||
local cache = {} -- cache key -> model
|
||||
local order = {} -- cache key, least recently used first
|
||||
|
||||
-- The key is the species for a normal model and species+SHINY for a shiny
|
||||
-- one, so the two are separate entries that cannot overwrite each other.
|
||||
--
|
||||
-- They MUST be separate. The model table carries the decoded textures and
|
||||
-- the lazily-built love Images hanging off them, and it is deliberately
|
||||
-- shared by both sides and both VR eyes -- so a single entry per species
|
||||
-- would mean a shiny Rattata and an ordinary one in the same fight fighting
|
||||
-- over one texture set, and whichever loaded last would colour both.
|
||||
local SHINY = 1000 -- clear of the 1..151 dex range
|
||||
|
||||
local function cacheKey(species, shiny)
|
||||
if not species then return nil end
|
||||
return shiny and (species + SHINY) or species
|
||||
end
|
||||
|
||||
-- Four, because a mirror match between a shiny and a normal of the SAME
|
||||
-- species is now two distinct models rather than one shared table, and both
|
||||
-- sides must survive a fifth species being called out mid-fight. See keep().
|
||||
StadiumPack.KEEP = 4
|
||||
|
||||
local function touch(species)
|
||||
@@ -520,30 +548,48 @@ end
|
||||
-- So the mode says, every frame, which two species are actually standing
|
||||
-- there (see Stadium.update). With KEEP at 4 and two sides, the two in use
|
||||
-- are always the two most recent and cannot reach the front of the queue.
|
||||
function StadiumPack.keep(species)
|
||||
if species and cache[species] then touch(species) end
|
||||
function StadiumPack.keep(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then touch(key) end
|
||||
end
|
||||
|
||||
-- Whether a pack for this species is on disk at all. Cheap enough to ask
|
||||
-- before a battle commits to the mode, and the honest test: a mod
|
||||
-- installed without its assets folder must decline rather than error.
|
||||
function StadiumPack.available(species)
|
||||
if cache[species] then return true end
|
||||
return readPack(species) ~= nil
|
||||
--
|
||||
-- Asked WITHOUT the shiny flag on purpose by the callers that gate the mode:
|
||||
-- whether a species can be modelled at all is a question about its normal
|
||||
-- pack. A missing shiny variant does not disqualify the species, it just
|
||||
-- means that one mon is drawn in its ordinary colours.
|
||||
function StadiumPack.available(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then return true end
|
||||
return readPack(species, shiny) ~= nil
|
||||
end
|
||||
|
||||
-- The model for a National Dex number (1..151), or nil.
|
||||
function StadiumPack.load(species)
|
||||
--
|
||||
-- `shiny` selects the recoloured variant. When a species has no shiny pack
|
||||
-- -- an install from before rev 3, a recolour that failed at extraction, a
|
||||
-- species we have no colours for -- this FALLS BACK to the normal model
|
||||
-- rather than returning nil. The alternative is a shiny Pokemon that drops
|
||||
-- to a flat 2D pic while its ordinary twin stands in 3D, which reads as a
|
||||
-- bug; wrong colours read as a mod that has not finished installing.
|
||||
function StadiumPack.load(species, shiny)
|
||||
if not (species and species >= 1 and species <= 151) then return nil end
|
||||
local hit = cache[species]
|
||||
local key = cacheKey(species, shiny)
|
||||
local hit = cache[key]
|
||||
if hit ~= nil then
|
||||
touch(species)
|
||||
touch(key)
|
||||
return hit or nil
|
||||
end
|
||||
|
||||
local bytes = readPack(species)
|
||||
local bytes = readPack(species, shiny)
|
||||
if not bytes and shiny then
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
if not bytes then
|
||||
cache[species] = false
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -562,14 +608,22 @@ function StadiumPack.load(species)
|
||||
return m
|
||||
end)
|
||||
if not ok then
|
||||
V.mod.log:warn("stadium: %03d.dsm did not read: %s -- that Pokemon "
|
||||
.. "falls back to its flat pic", species, tostring(model))
|
||||
cache[species] = false
|
||||
V.mod.log:warn("stadium: %s did not read: %s -- that Pokemon "
|
||||
.. "falls back to its flat pic",
|
||||
packName("", species, shiny):sub(2), tostring(model))
|
||||
-- A corrupt SHINY pack must not cost the species its model: fall back to
|
||||
-- the normal one, exactly as a missing file does above.
|
||||
if shiny then
|
||||
cache[key] = false
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
cache[species] = model
|
||||
touch(species)
|
||||
model.shiny = shiny and true or nil
|
||||
cache[key] = model
|
||||
touch(key)
|
||||
return model
|
||||
end
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ local WorldCurve = V.require("WorldCurve")
|
||||
local ViewBox = V.require("ViewBox")
|
||||
local OverworldBattle = V.require("OverworldBattle")
|
||||
local BattleExit = V.require("BattleExit")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyUI = V.require("ShinyUI")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
@@ -1224,6 +1226,39 @@ end
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- ------- shiny Pokemon
|
||||
--
|
||||
-- ON, always, with no row to switch it off: shininess is a property of the
|
||||
-- Pokemon rather than a display mode, and a Pokemon that is shiny in one
|
||||
-- player's save and not another's is not a Pokemon, it is a setting.
|
||||
--
|
||||
-- It rests on a fact the engine already ships. Gen 1 has no shininess of its
|
||||
-- own, but it has the four DVs Gen 2 reads to decide it, and
|
||||
-- src/pokemon/Stats.lua:90 carries that reading -- the engine's own comment
|
||||
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
||||
-- So nothing new is stored on a Pokemon and nothing has to migrate: every
|
||||
-- save ever made already contains the answer, and this only starts drawing
|
||||
-- it. See lib/Shiny.lua for why deriving beats storing.
|
||||
--
|
||||
-- Three seams, each in its own file with its own reasoning:
|
||||
-- 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)
|
||||
--
|
||||
-- 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()
|
||||
|
||||
-- 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
|
||||
-- rather than leaving it absent until each mon next changes.
|
||||
mod.events:on("save.loaded", function() ShinyBattle.markParty() end)
|
||||
mod.events:on("save.created", function() ShinyBattle.markParty() end)
|
||||
|
||||
-- ------- the free-roam rungs' inputs and their walk
|
||||
--
|
||||
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
-- generated fixture: {dex, srcR,srcG,srcB, wantR,wantG,wantB}
|
||||
return {
|
||||
{6,222,131,123,160,165,185},
|
||||
{6,189,115,41,105,96,134},
|
||||
{6,16,16,8,12,11,13},
|
||||
{6,115,57,24,60,58,81},
|
||||
{6,230,139,57,131,122,165},
|
||||
{6,213,164,74,139,126,161},
|
||||
{6,41,32,24,31,30,35},
|
||||
{6,8,49,57,39,37,26},
|
||||
{6,82,41,8,39,36,54},
|
||||
{6,246,172,74,152,138,182},
|
||||
{6,172,98,32,92,84,120},
|
||||
{6,65,49,24,43,39,50},
|
||||
{6,213,131,123,157,161,179},
|
||||
{6,156,156,148,152,151,153},
|
||||
{6,255,246,156,209,193,218},
|
||||
{6,205,164,74,137,123,156},
|
||||
{6,24,90,90,65,61,49},
|
||||
{6,172,98,24,88,80,116},
|
||||
{6,123,24,24,61,68,86},
|
||||
{6,16,90,82,62,55,44},
|
||||
{6,230,148,65,137,127,168},
|
||||
{6,139,74,24,72,67,96},
|
||||
{6,156,90,41,89,84,113},
|
||||
{6,123,131,131,128,127,126},
|
||||
{6,246,164,90,157,148,188},
|
||||
{6,222,139,57,128,119,160},
|
||||
{6,98,57,16,52,47,67},
|
||||
{6,32,131,131,94,87,69},
|
||||
{6,148,90,32,82,76,105},
|
||||
{6,197,123,115,146,149,166},
|
||||
{6,222,106,106,150,157,178},
|
||||
{6,238,222,106,176,156,188},
|
||||
{6,32,41,32,38,35,36},
|
||||
{6,32,90,90,68,64,54},
|
||||
{6,32,49,49,43,41,38},
|
||||
{6,82,16,16,41,45,57},
|
||||
{6,189,189,189,189,189,189},
|
||||
{6,148,90,41,86,81,108},
|
||||
{6,8,16,24,17,18,14},
|
||||
{6,90,98,139,114,121,108},
|
||||
{42,123,41,115,16,15,26},
|
||||
{42,90,131,148,26,33,28},
|
||||
{42,90,156,189,27,42,32},
|
||||
{42,57,8,57,5,5,11},
|
||||
{42,123,106,197,30,44,46},
|
||||
{42,115,148,222,29,55,46},
|
||||
{42,98,57,98,17,17,22},
|
||||
{42,49,65,74,14,17,15},
|
||||
{42,197,189,205,46,50,53},
|
||||
{42,32,164,230,20,46,27},
|
||||
{42,41,205,246,19,52,24},
|
||||
{42,74,131,164,24,35,28},
|
||||
{42,90,98,189,27,42,40},
|
||||
{42,41,156,197,20,40,24},
|
||||
{42,98,90,180,27,40,40},
|
||||
{42,24,115,164,15,32,20},
|
||||
{42,164,189,205,39,53,44},
|
||||
{42,164,106,180,30,33,42},
|
||||
{42,74,148,213,25,47,34},
|
||||
{42,82,139,156,25,34,27},
|
||||
{42,90,180,230,25,55,34},
|
||||
{42,74,115,139,23,31,25},
|
||||
{42,49,189,230,21,49,26},
|
||||
{42,65,82,98,18,22,20},
|
||||
{42,172,115,189,31,35,45},
|
||||
{42,49,123,189,21,39,28},
|
||||
{42,172,197,197,42,50,42},
|
||||
{42,189,74,164,28,25,41},
|
||||
{42,65,57,123,18,26,27},
|
||||
{42,49,16,49,6,6,10},
|
||||
{42,32,57,74,11,16,12},
|
||||
{42,65,156,222,23,49,33},
|
||||
{42,106,98,189,29,43,43},
|
||||
{42,222,213,222,51,51,58},
|
||||
{42,172,197,205,41,53,44},
|
||||
{42,238,238,238,60,60,60},
|
||||
{42,189,197,205,46,53,49},
|
||||
{42,41,8,49,5,6,10},
|
||||
{42,49,0,49,3,3,9},
|
||||
{42,205,213,213,50,55,50},
|
||||
{79,139,82,57,28,18,31},
|
||||
{79,90,74,57,21,16,21},
|
||||
{79,246,106,74,48,17,63},
|
||||
{79,222,131,123,46,27,59},
|
||||
{79,156,131,82,36,24,34},
|
||||
{79,213,90,90,38,24,52},
|
||||
{79,222,197,148,62,31,57},
|
||||
{79,230,205,148,66,29,58},
|
||||
{79,213,189,156,58,34,56},
|
||||
{79,197,189,139,51,33,44},
|
||||
{79,180,106,90,37,26,42},
|
||||
{79,230,115,115,43,24,62},
|
||||
{79,148,82,82,29,24,34},
|
||||
{79,246,156,156,50,24,76},
|
||||
{79,24,8,8,4,3,5},
|
||||
{79,131,65,41,25,14,29},
|
||||
{79,213,98,82,40,23,51},
|
||||
{79,49,32,8,10,4,10},
|
||||
{79,123,74,49,25,16,27},
|
||||
{79,57,32,24,11,8,13},
|
||||
{79,180,57,65,28,20,39},
|
||||
{79,164,82,57,32,19,36},
|
||||
{79,148,106,82,32,24,34},
|
||||
{79,230,123,106,47,23,61},
|
||||
{79,238,115,98,47,21,63},
|
||||
{79,246,115,98,49,19,67},
|
||||
{79,238,139,131,49,24,68},
|
||||
{79,139,82,49,29,16,31},
|
||||
{79,246,131,106,53,20,68},
|
||||
{79,197,98,74,38,23,45},
|
||||
{79,213,106,98,41,25,53},
|
||||
{79,172,98,90,34,26,40},
|
||||
{79,197,98,82,38,24,46},
|
||||
{79,123,57,32,23,12,26},
|
||||
{79,189,189,180,48,44,46},
|
||||
{79,213,213,213,53,53,53},
|
||||
{79,115,106,82,27,22,26},
|
||||
{79,74,32,32,13,10,17},
|
||||
{79,148,74,57,28,19,33},
|
||||
{79,238,246,238,72,60,49},
|
||||
{146,172,131,0,231,152,197},
|
||||
{146,205,82,0,234,162,229},
|
||||
{146,57,57,156,183,215,205},
|
||||
{146,106,24,0,216,132,227},
|
||||
{146,24,24,32,162,177,172},
|
||||
{146,74,65,123,183,206,202},
|
||||
{146,213,172,82,230,199,219},
|
||||
{146,205,90,16,230,172,226},
|
||||
{146,246,197,8,235,179,209},
|
||||
{146,41,24,82,153,205,203},
|
||||
{146,255,230,115,245,212,229},
|
||||
{146,246,213,41,237,189,213},
|
||||
{146,74,90,131,188,208,196},
|
||||
{146,189,189,189,230,230,230},
|
||||
{146,115,74,0,227,135,199},
|
||||
{146,246,123,0,236,175,226},
|
||||
{146,197,131,8,231,165,210},
|
||||
{146,180,123,0,232,154,205},
|
||||
{146,106,115,65,204,182,186},
|
||||
{146,98,32,0,225,129,226},
|
||||
{146,255,180,0,237,177,215},
|
||||
{146,205,156,16,230,172,206},
|
||||
{146,197,156,74,225,196,215},
|
||||
{146,74,57,148,182,214,209},
|
||||
{146,255,123,0,237,177,228},
|
||||
{146,115,57,0,227,135,212},
|
||||
{146,246,255,222,253,245,245},
|
||||
{146,106,65,0,227,132,200},
|
||||
{146,98,16,0,210,129,226},
|
||||
{146,230,189,16,232,179,207},
|
||||
{146,255,230,41,240,190,212},
|
||||
{146,255,246,32,239,187,207},
|
||||
{146,213,156,41,227,187,214},
|
||||
{146,222,123,0,235,167,220},
|
||||
{146,164,98,0,231,150,209},
|
||||
{146,148,49,8,223,151,226},
|
||||
{146,90,65,0,225,127,187},
|
||||
{146,255,213,16,238,182,211},
|
||||
{146,24,32,49,156,191,168},
|
||||
{146,255,180,16,238,182,218},
|
||||
{96,131,82,57,175,159,190},
|
||||
{96,32,32,8,173,102,161},
|
||||
{96,197,156,0,213,138,216},
|
||||
{96,148,115,41,191,152,197},
|
||||
{96,57,57,41,160,144,158},
|
||||
{96,131,115,8,203,121,200},
|
||||
{96,65,57,16,180,115,180},
|
||||
{96,148,57,57,167,162,196},
|
||||
{96,131,32,49,142,142,195},
|
||||
{96,139,98,32,185,144,197},
|
||||
{96,123,82,49,175,152,189},
|
||||
{96,213,213,213,234,234,234},
|
||||
{96,139,90,65,179,165,192},
|
||||
{96,131,90,57,178,159,190},
|
||||
{96,164,148,98,200,185,201},
|
||||
{96,139,98,8,193,124,205},
|
||||
{96,106,98,49,183,150,182},
|
||||
{96,115,90,24,186,132,193},
|
||||
{96,115,98,32,188,139,190},
|
||||
{96,57,49,24,166,126,170},
|
||||
{96,172,131,8,204,135,210},
|
||||
{96,65,57,49,158,149,163},
|
||||
{96,106,98,82,178,169,180},
|
||||
{96,49,49,32,159,136,155},
|
||||
{96,180,139,8,206,138,211},
|
||||
{96,131,123,90,189,176,189},
|
||||
{96,156,148,123,201,193,201},
|
||||
{96,106,90,8,199,113,198},
|
||||
{96,148,115,24,195,140,201},
|
||||
{96,115,115,123,186,188,186},
|
||||
{96,106,65,57,164,155,181},
|
||||
{96,255,197,0,219,159,223},
|
||||
{96,230,222,205,238,233,239},
|
||||
{96,115,98,57,181,156,185},
|
||||
{96,180,172,131,211,199,211},
|
||||
{96,148,123,57,192,162,196},
|
||||
{96,230,180,0,216,150,220},
|
||||
{96,156,115,0,202,122,211},
|
||||
{96,172,123,32,195,152,205},
|
||||
{96,123,82,57,174,157,188},
|
||||
{140,49,49,49,101,101,101},
|
||||
{140,255,8,131,174,155,151},
|
||||
{140,139,82,32,135,138,118},
|
||||
{140,123,0,8,124,113,96},
|
||||
{140,230,24,24,169,162,149},
|
||||
{140,32,24,8,81,85,73},
|
||||
{140,230,156,156,212,210,205},
|
||||
{140,82,0,0,106,98,83},
|
||||
{140,131,82,24,128,132,111},
|
||||
{140,74,0,0,103,95,80},
|
||||
{140,222,172,172,214,212,209},
|
||||
{140,82,74,82,123,121,122},
|
||||
{140,197,106,106,182,179,173},
|
||||
{140,246,49,74,184,175,165},
|
||||
{140,255,16,123,177,159,154},
|
||||
{140,156,24,57,143,129,120},
|
||||
{140,156,82,8,135,139,111},
|
||||
{140,255,131,156,214,208,203},
|
||||
{140,222,213,205,224,225,223},
|
||||
{140,82,49,16,106,109,92},
|
||||
{140,205,172,172,207,206,204},
|
||||
{140,230,172,172,217,215,212},
|
||||
{140,230,82,115,188,180,174},
|
||||
{140,255,82,189,198,183,182},
|
||||
{140,222,156,90,185,187,175},
|
||||
{140,180,98,16,145,150,125},
|
||||
{140,238,115,123,202,197,190},
|
||||
{140,65,8,16,100,92,82},
|
||||
{140,172,74,82,161,157,151},
|
||||
{140,255,172,197,228,223,220},
|
||||
{140,197,57,65,166,160,152},
|
||||
{140,205,131,49,164,166,152},
|
||||
{140,57,57,57,106,106,106},
|
||||
{140,131,32,49,134,125,115},
|
||||
{140,57,32,32,101,98,94},
|
||||
{140,123,8,41,125,110,100},
|
||||
{140,246,32,82,178,167,158},
|
||||
{140,164,98,32,144,147,127},
|
||||
{140,255,0,90,171,155,147},
|
||||
{140,255,106,172,206,195,192},
|
||||
{23,139,106,16,46,41,75},
|
||||
{23,172,131,24,58,53,94},
|
||||
{23,106,8,24,29,50,57},
|
||||
{23,246,197,49,85,73,149},
|
||||
{23,255,213,41,88,69,153},
|
||||
{23,238,180,16,74,64,126},
|
||||
{23,222,123,131,101,139,158},
|
||||
{23,246,213,82,99,81,165},
|
||||
{23,156,106,172,93,115,97},
|
||||
{23,90,16,24,29,44,50},
|
||||
{23,164,90,98,85,99,106},
|
||||
{23,213,164,41,77,71,119},
|
||||
{23,115,74,98,65,77,75},
|
||||
{23,230,189,106,98,92,160},
|
||||
{23,115,65,123,62,79,67},
|
||||
{23,222,172,74,87,82,140},
|
||||
{23,213,172,90,93,88,139},
|
||||
{23,255,213,57,91,73,161},
|
||||
{23,156,115,164,96,113,101},
|
||||
{23,222,156,8,63,56,116},
|
||||
{23,123,82,123,71,83,76},
|
||||
{23,98,49,115,52,71,55},
|
||||
{23,106,74,65,58,63,70},
|
||||
{23,180,180,180,135,135,135},
|
||||
{23,115,49,90,52,71,67},
|
||||
{23,98,57,90,52,64,59},
|
||||
{23,180,123,180,102,125,112},
|
||||
{23,90,65,41,42,44,56},
|
||||
{23,238,172,24,73,66,130},
|
||||
{23,222,164,8,65,56,116},
|
||||
{23,131,106,41,55,52,77},
|
||||
{23,180,139,164,110,129,126},
|
||||
{23,32,8,24,12,18,17},
|
||||
{23,213,164,24,71,62,115},
|
||||
{23,131,74,148,73,94,77},
|
||||
{23,65,0,16,15,30,34},
|
||||
{23,139,106,49,59,58,83},
|
||||
{23,189,139,24,62,57,103},
|
||||
{23,131,65,74,64,78,83},
|
||||
{23,65,49,0,18,15,34},
|
||||
{114,205,197,197,197,197,205},
|
||||
{114,180,180,189,180,189,180},
|
||||
{114,123,123,230,123,230,123},
|
||||
{114,205,205,255,205,255,205},
|
||||
{114,189,49,74,49,74,189},
|
||||
{114,222,213,222,213,222,222},
|
||||
{114,82,82,205,82,205,82},
|
||||
{114,131,131,238,131,238,131},
|
||||
{114,82,82,164,82,164,82},
|
||||
{114,230,230,255,230,255,230},
|
||||
{114,41,41,32,41,32,41},
|
||||
{114,148,32,57,32,57,148},
|
||||
{114,65,65,90,65,90,65},
|
||||
{114,172,172,180,172,180,172},
|
||||
{114,123,123,213,123,213,123},
|
||||
{114,238,230,238,230,238,238},
|
||||
{114,41,41,49,41,49,41},
|
||||
{114,238,238,238,238,238,238},
|
||||
{114,205,197,205,197,205,205},
|
||||
{114,230,230,222,230,222,230},
|
||||
{114,90,90,90,90,90,90},
|
||||
{114,255,255,238,255,238,255},
|
||||
{114,106,106,238,106,238,106},
|
||||
{114,246,106,131,106,131,246},
|
||||
{114,49,49,123,49,123,49},
|
||||
{114,16,16,41,16,41,16},
|
||||
{114,255,255,246,255,246,255},
|
||||
{114,8,8,16,8,16,8},
|
||||
{114,205,82,106,82,106,205},
|
||||
{114,205,205,213,205,213,205},
|
||||
{114,32,32,24,32,24,32},
|
||||
{114,41,49,106,49,106,41},
|
||||
{114,41,41,98,41,98,41},
|
||||
{114,123,123,148,123,148,123},
|
||||
{114,156,148,205,148,205,156},
|
||||
{114,156,41,57,41,57,156},
|
||||
{114,222,90,106,90,106,222},
|
||||
{114,57,57,65,57,65,57},
|
||||
{114,123,123,205,123,205,123},
|
||||
{114,82,74,82,74,82,82},
|
||||
{134,197,205,213,212,197,213},
|
||||
{134,205,222,230,224,205,230},
|
||||
{134,213,222,238,238,213,236},
|
||||
{134,32,90,90,58,32,90},
|
||||
{134,164,189,189,175,164,189},
|
||||
{134,65,90,131,131,65,126},
|
||||
{134,32,139,172,128,32,172},
|
||||
{134,156,172,189,188,156,189},
|
||||
{134,255,246,148,148,255,187},
|
||||
{134,0,8,16,15,0,16},
|
||||
{134,24,41,82,82,24,73},
|
||||
{134,57,148,180,144,57,180},
|
||||
{134,123,197,197,156,123,197},
|
||||
{134,148,197,205,182,148,205},
|
||||
{134,24,8,0,5,24,0},
|
||||
{134,65,90,148,148,65,136},
|
||||
{134,164,213,222,199,164,222},
|
||||
{134,41,74,123,123,41,119},
|
||||
{134,8,16,65,65,8,47},
|
||||
{134,213,222,156,156,222,195},
|
||||
{134,74,57,32,32,74,34},
|
||||
{134,65,106,148,144,65,148},
|
||||
{134,24,41,98,98,24,82},
|
||||
{134,115,123,123,119,115,123},
|
||||
{134,49,90,98,79,49,98},
|
||||
{134,131,172,197,186,131,197},
|
||||
{134,0,24,16,3,0,24},
|
||||
{134,32,82,98,78,32,98},
|
||||
{134,156,180,205,203,156,205},
|
||||
{134,148,213,222,190,148,222},
|
||||
{134,139,197,205,177,139,205},
|
||||
{134,32,49,98,98,32,85},
|
||||
{134,82,98,148,148,82,134},
|
||||
{134,41,49,115,115,41,90},
|
||||
{134,222,230,230,226,222,230},
|
||||
{134,106,106,115,115,106,111},
|
||||
{134,32,57,106,106,32,98},
|
||||
{134,32,106,148,126,32,148},
|
||||
{134,32,90,131,118,32,131},
|
||||
{134,41,90,139,134,41,139},
|
||||
{143,82,106,90,105,123,120},
|
||||
{143,148,115,98,154,149,125},
|
||||
{143,230,222,213,230,230,221},
|
||||
{143,139,65,65,149,121,94},
|
||||
{143,16,0,8,63,15,15},
|
||||
{143,213,180,123,197,203,154},
|
||||
{143,189,90,90,181,154,127},
|
||||
{143,115,57,41,130,113,70},
|
||||
{143,57,57,57,82,82,82},
|
||||
{143,172,139,90,165,169,124},
|
||||
{143,189,156,123,186,186,150},
|
||||
{143,222,222,222,226,226,226},
|
||||
{143,255,230,197,243,245,214},
|
||||
{143,230,230,230,233,233,233},
|
||||
{143,197,197,180,197,201,192},
|
||||
{143,222,205,123,192,210,156},
|
||||
{143,238,230,180,219,231,199},
|
||||
{143,90,90,65,100,110,90},
|
||||
{143,213,197,123,188,203,154},
|
||||
{143,205,197,123,180,198,153},
|
||||
{143,90,82,57,103,110,83},
|
||||
{143,205,180,139,196,200,164},
|
||||
{143,213,213,189,208,214,201},
|
||||
{143,115,65,65,130,111,91},
|
||||
{143,164,131,74,157,164,108},
|
||||
{143,213,197,156,202,209,178},
|
||||
{143,123,106,65,127,136,92},
|
||||
{143,180,148,148,184,175,167},
|
||||
{143,205,197,156,194,203,176},
|
||||
{143,123,82,74,136,124,100},
|
||||
{143,24,8,8,60,46,32},
|
||||
{143,255,238,189,235,244,208},
|
||||
{143,230,222,205,227,229,215},
|
||||
{143,213,180,131,200,205,160},
|
||||
{143,131,65,65,142,118,93},
|
||||
{143,222,189,139,208,213,167},
|
||||
{143,24,16,8,60,60,32},
|
||||
{143,205,189,156,199,203,176},
|
||||
{143,156,148,98,147,159,127},
|
||||
{143,164,131,98,165,165,128},
|
||||
{95,57,49,65,34,41,31},
|
||||
{95,0,197,230,144,42,0},
|
||||
{95,197,197,238,181,201,71},
|
||||
{95,0,16,65,41,37,0},
|
||||
{95,148,156,156,99,92,91},
|
||||
{95,238,238,230,132,126,167},
|
||||
{95,98,98,98,61,61,61},
|
||||
{95,148,148,156,98,99,91},
|
||||
{95,16,32,156,94,98,10},
|
||||
{95,0,49,106,66,46,0},
|
||||
{95,0,123,197,123,65,0},
|
||||
{95,0,180,238,149,59,0},
|
||||
{95,0,8,16,10,7,0},
|
||||
{95,139,148,164,106,101,83},
|
||||
{95,65,74,82,51,47,41},
|
||||
{95,189,197,197,128,115,113},
|
||||
{95,0,90,197,123,85,0},
|
||||
{95,106,115,131,82,79,66},
|
||||
{95,0,139,205,128,60,0},
|
||||
{95,131,123,148,85,94,76},
|
||||
{95,0,24,49,31,20,0},
|
||||
{95,0,8,98,57,61,0},
|
||||
{95,98,98,115,70,72,61},
|
||||
{95,139,139,139,87,87,87},
|
||||
{95,49,49,57,35,36,31},
|
||||
{95,0,74,131,82,48,0},
|
||||
{95,115,106,131,74,82,66},
|
||||
{95,0,115,197,123,70,0},
|
||||
{95,156,148,180,106,121,84},
|
||||
{95,123,123,139,86,87,77},
|
||||
{95,0,41,156,98,86,0},
|
||||
{95,0,106,172,108,57,0},
|
||||
{95,16,57,156,98,85,10},
|
||||
{95,0,115,148,93,35,0},
|
||||
{95,205,205,230,164,176,96},
|
||||
{95,131,139,156,100,96,80},
|
||||
{95,189,197,205,140,128,106},
|
||||
{95,98,106,131,82,80,61},
|
||||
{95,213,213,246,194,216,71},
|
||||
{95,24,32,32,20,16,15},
|
||||
{35,180,74,65,180,65,123},
|
||||
{35,197,82,82,197,82,140},
|
||||
{35,172,131,131,172,131,152},
|
||||
{35,65,41,41,65,41,53},
|
||||
{35,213,230,230,213,230,230},
|
||||
{35,172,74,65,172,65,119},
|
||||
{35,148,164,164,148,164,164},
|
||||
{35,148,106,98,148,98,123},
|
||||
{35,90,65,57,90,57,74},
|
||||
{35,57,57,156,57,57,156},
|
||||
{35,222,164,164,222,164,193},
|
||||
{35,255,222,222,255,222,238},
|
||||
{35,8,8,0,1,8,0},
|
||||
{35,148,148,156,148,148,156},
|
||||
{35,49,41,16,15,52,10},
|
||||
{35,222,246,238,222,246,238},
|
||||
{35,148,82,90,148,82,115},
|
||||
{35,57,8,8,57,8,33},
|
||||
{35,131,131,131,131,131,131},
|
||||
{35,255,189,197,255,189,222},
|
||||
{35,205,90,82,205,82,144},
|
||||
{35,230,197,197,230,197,214},
|
||||
{35,238,139,139,238,139,189},
|
||||
{35,180,115,115,180,115,148},
|
||||
{35,65,57,57,65,57,57},
|
||||
{35,172,139,131,172,131,152},
|
||||
{35,131,32,24,131,24,78},
|
||||
{35,65,49,8,9,69,0},
|
||||
{35,41,24,8,8,44,2},
|
||||
{35,180,139,123,180,123,152},
|
||||
{35,98,123,123,98,123,123},
|
||||
{35,41,41,115,41,41,115},
|
||||
{35,106,16,16,106,16,61},
|
||||
{35,106,131,131,106,131,131},
|
||||
{35,16,16,24,16,16,24},
|
||||
{35,156,139,139,156,139,139},
|
||||
{35,123,32,24,123,24,74},
|
||||
{35,49,24,24,49,24,37},
|
||||
{35,16,16,41,16,16,41},
|
||||
{35,189,164,172,189,164,176},
|
||||
{36,230,148,172,230,148,189},
|
||||
{36,255,148,164,255,148,202},
|
||||
{36,156,123,123,156,123,123},
|
||||
{36,189,156,148,189,148,169},
|
||||
{36,98,41,24,98,24,61},
|
||||
{36,74,0,0,74,0,37},
|
||||
{36,148,82,74,148,74,111},
|
||||
{36,65,49,49,65,49,49},
|
||||
{36,238,197,180,238,180,209},
|
||||
{36,230,180,172,230,172,201},
|
||||
{36,24,16,16,24,16,20},
|
||||
{36,246,148,164,246,148,197},
|
||||
{36,180,156,156,180,156,156},
|
||||
{36,49,49,49,49,49,49},
|
||||
{36,222,189,189,222,189,206},
|
||||
{36,98,90,90,98,90,90},
|
||||
{36,148,131,123,148,131,123},
|
||||
{36,222,222,222,222,222,222},
|
||||
{36,222,189,180,222,180,201},
|
||||
{36,213,156,156,213,156,185},
|
||||
{36,222,180,180,222,180,201},
|
||||
{36,197,123,115,197,115,156},
|
||||
{36,230,139,156,230,139,185},
|
||||
{36,115,90,90,115,90,90},
|
||||
{36,32,8,8,32,8,20},
|
||||
{36,65,49,41,65,41,53},
|
||||
{36,8,0,0,8,0,4},
|
||||
{36,98,32,32,98,32,65},
|
||||
{36,164,98,98,164,98,131},
|
||||
{36,106,98,90,106,98,90},
|
||||
{36,230,205,197,230,197,214},
|
||||
{36,246,213,205,246,205,226},
|
||||
{36,164,148,148,164,148,148},
|
||||
{36,238,164,189,238,164,201},
|
||||
{36,156,131,123,156,131,123},
|
||||
{36,230,172,180,230,172,201},
|
||||
{36,24,8,8,24,8,16},
|
||||
{36,106,90,90,106,90,90},
|
||||
{36,213,197,189,213,189,201},
|
||||
{36,246,172,156,246,156,201},
|
||||
{39,255,189,197,250,194,250},
|
||||
{39,222,255,255,240,255,222},
|
||||
{39,139,131,139,139,131,139},
|
||||
{39,74,148,139,115,154,68},
|
||||
{39,222,246,246,235,248,220},
|
||||
{39,131,156,148,131,156,148},
|
||||
{39,41,82,82,64,85,38},
|
||||
{39,189,90,98,182,97,182},
|
||||
{39,180,139,148,177,142,177},
|
||||
{39,205,123,131,199,129,199},
|
||||
{39,180,148,156,178,150,178},
|
||||
{39,74,164,148,124,171,67},
|
||||
{39,230,230,238,234,239,229},
|
||||
{39,106,197,189,157,204,99},
|
||||
{39,74,90,90,74,90,90},
|
||||
{39,230,139,148,223,146,223},
|
||||
{39,41,98,82,73,102,37},
|
||||
{39,246,156,164,239,163,239},
|
||||
{39,180,197,197,180,197,197},
|
||||
{39,139,90,106,135,94,135},
|
||||
{39,32,74,74,55,77,29},
|
||||
{39,131,82,90,127,86,127},
|
||||
{39,32,106,90,73,112,26},
|
||||
{39,164,189,189,178,191,162},
|
||||
{39,41,115,98,82,121,35},
|
||||
{39,246,172,172,240,178,240},
|
||||
{39,131,205,197,172,211,125},
|
||||
{39,197,106,106,190,113,190},
|
||||
{39,98,65,74,96,67,96},
|
||||
{39,205,246,246,228,249,202},
|
||||
{39,98,172,156,139,178,92},
|
||||
{39,106,180,172,147,186,100},
|
||||
{39,24,57,57,42,59,22},
|
||||
{39,172,197,197,186,199,170},
|
||||
{39,115,139,148,115,139,148},
|
||||
{39,32,106,98,73,112,26},
|
||||
{39,230,180,197,226,184,226},
|
||||
{39,57,90,82,75,92,55},
|
||||
{39,74,148,131,115,154,68},
|
||||
{39,189,238,238,216,242,185},
|
||||
{40,164,123,131,161,126,161},
|
||||
{40,139,131,131,139,131,131},
|
||||
{40,156,139,139,156,139,139},
|
||||
{40,222,164,180,218,168,218},
|
||||
{40,222,205,205,221,206,221},
|
||||
{40,255,238,246,254,239,254},
|
||||
{40,230,164,164,225,169,225},
|
||||
{40,131,131,131,131,131,131},
|
||||
{40,197,115,123,191,121,191},
|
||||
{40,82,106,106,82,106,106},
|
||||
{40,230,172,189,226,176,226},
|
||||
{40,148,156,164,148,156,164},
|
||||
{40,74,90,90,74,90,90},
|
||||
{40,82,156,139,123,162,76},
|
||||
{40,164,115,131,160,119,160},
|
||||
{40,82,49,65,80,51,80},
|
||||
{40,65,106,106,88,109,62},
|
||||
{40,74,98,98,74,98,98},
|
||||
{40,189,106,106,183,112,183},
|
||||
{40,222,246,246,235,248,220},
|
||||
{40,222,197,205,220,199,220},
|
||||
{40,115,139,148,115,139,148},
|
||||
{40,238,246,246,242,247,237},
|
||||
{40,74,131,123,106,135,70},
|
||||
{40,156,115,131,153,118,153},
|
||||
{40,82,115,115,100,117,80},
|
||||
{40,230,148,148,224,154,224},
|
||||
{40,148,213,205,184,218,143},
|
||||
{40,246,189,197,242,193,242},
|
||||
{40,139,156,164,139,156,164},
|
||||
{40,172,189,197,186,199,170},
|
||||
{40,230,172,180,226,176,226},
|
||||
{40,164,131,139,162,133,162},
|
||||
{40,205,148,156,201,152,201},
|
||||
{40,238,246,238,238,246,238},
|
||||
{40,156,115,123,153,118,153},
|
||||
{40,156,106,123,152,110,152},
|
||||
{40,197,131,131,192,136,192},
|
||||
{40,255,164,164,248,171,248},
|
||||
{40,131,172,164,154,175,128},
|
||||
{130,123,197,230,220,80,70},
|
||||
{130,230,222,180,230,222,180},
|
||||
{130,255,222,131,255,222,131},
|
||||
{130,57,131,180,148,53,47},
|
||||
{130,106,82,41,106,82,41},
|
||||
{130,98,180,238,232,56,43},
|
||||
{130,65,90,98,80,55,53},
|
||||
{130,189,172,123,189,172,123},
|
||||
{130,65,123,148,121,58,53},
|
||||
{130,221,221,221,221,221,221},
|
||||
{130,57,82,106,87,49,47},
|
||||
{130,222,222,222,222,222,222},
|
||||
{130,189,164,106,189,164,106},
|
||||
{130,85,85,85,85,85,85},
|
||||
{130,172,148,98,172,148,98},
|
||||
{130,115,189,230,221,73,62},
|
||||
{130,189,164,74,189,164,74},
|
||||
{130,230,246,246,223,171,167},
|
||||
{130,205,180,90,205,180,90},
|
||||
{130,82,57,16,82,57,16},
|
||||
{130,238,90,106,238,90,106},
|
||||
{130,156,180,164,156,180,164},
|
||||
{130,82,98,106,82,98,106},
|
||||
{130,164,230,246,239,106,97},
|
||||
{130,133,133,133,133,133,133},
|
||||
{130,156,131,57,156,131,57},
|
||||
{130,90,49,106,87,43,40},
|
||||
{130,98,115,98,98,115,98},
|
||||
{130,98,98,65,98,98,65},
|
||||
{130,148,49,65,148,49,65},
|
||||
{130,156,156,156,156,156,156},
|
||||
{130,49,49,82,67,42,40},
|
||||
{130,230,222,230,230,222,230},
|
||||
{130,153,153,153,153,153,153},
|
||||
{130,255,230,255,255,150,143},
|
||||
{130,8,82,131,107,13,7},
|
||||
{130,139,213,255,255,81,68},
|
||||
{130,32,16,32,26,14,13},
|
||||
{130,164,57,65,164,57,65},
|
||||
{130,82,82,32,82,82,32},
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
-- The shiny system, headless.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/shiny_test.lua [--mod=DIR]
|
||||
--
|
||||
-- Run from the PROJECT ROOT (it requires src.pokemon.Stats, the engine's own
|
||||
-- shiny predicate -- the point being that we agree with the engine rather
|
||||
-- than carry a second copy of the rule).
|
||||
--
|
||||
-- Two halves:
|
||||
--
|
||||
-- the DV model that a decided mon really does satisfy the Gen 2
|
||||
-- pattern, that a miss really does not, that the odds
|
||||
-- are the number they claim, and that the derived HP DV
|
||||
-- is kept legal.
|
||||
-- the recolour against a fixture of real (normal, shiny) colour
|
||||
-- pairs lifted from the verified texture set, so the
|
||||
-- Lua transform is checked against the Python that
|
||||
-- produced the shipped colours rather than against
|
||||
-- itself.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local args = {}
|
||||
for _, a in ipairs({ ... }) do
|
||||
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
|
||||
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
|
||||
end
|
||||
|
||||
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
|
||||
|
||||
-- ------- the mod namespace, enough of it
|
||||
|
||||
local loaded = {}
|
||||
local V = {}
|
||||
function V.require(name)
|
||||
if loaded[name] == nil then
|
||||
loaded[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(V)
|
||||
end
|
||||
return loaded[name]
|
||||
end
|
||||
function V.data(name)
|
||||
return assert(loadfile(MOD .. "/data/" .. name .. ".lua"))(V)
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
-- ------- a tiny harness
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function ok(cond, what)
|
||||
if cond then
|
||||
pass = pass + 1
|
||||
else
|
||||
fail = fail + 1
|
||||
io.write("FAIL: " .. what .. "\n")
|
||||
end
|
||||
end
|
||||
local function eq(got, want, what)
|
||||
ok(got == want, ("%s (got %s, want %s)")
|
||||
:format(what, tostring(got), tostring(want)))
|
||||
end
|
||||
|
||||
-- always-hit and always-miss stand-ins for the odds roll
|
||||
local function hit() return 1 end
|
||||
local function miss() return 2 end
|
||||
|
||||
local function mon(dvs, level, species)
|
||||
return { species = species, level = level, dvs = dvs,
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } }
|
||||
end
|
||||
|
||||
-- ------- the DV model
|
||||
|
||||
do
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
ok(Shiny.isShiny(m), "a hit produces a mon the ENGINE calls shiny")
|
||||
ok(Stats.isShiny(m.dvs), "and the engine's own predicate agrees")
|
||||
eq(m.dvs.defense, 10, "defense pinned to 10")
|
||||
eq(m.dvs.speed, 10, "speed pinned to 10")
|
||||
eq(m.dvs.special, 10, "special pinned to 10")
|
||||
eq(m.shiny, true, "the cached flag is set")
|
||||
end
|
||||
|
||||
do
|
||||
-- nearest legal Attack, so a shiny encounter is not also a stat reroll
|
||||
local cases = { [0] = 2, [4] = 3, [5] = 6, [9] = 10, [13] = 14, [15] = 15 }
|
||||
for from, want in pairs(cases) do
|
||||
local m = mon({ attack = from, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
eq(m.dvs.attack, want, ("attack %d moves to the nearest legal %d")
|
||||
:format(from, want))
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- the HP DV is derived from the low bits of the other four; a write that
|
||||
-- forgets to resync it produces a mon no real game could make
|
||||
for atk = 0, 15 do
|
||||
local m = mon({ attack = atk, defense = 3, speed = 7, special = 1, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
local want = (m.dvs.attack % 2) * 8 + (m.dvs.defense % 2) * 4
|
||||
+ (m.dvs.speed % 2) * 2 + (m.dvs.special % 2)
|
||||
eq(m.dvs.hp, want, "HP DV stays derived from the other four")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- a MISS must not leave an accidentally-shiny mon shiny, or the rate is
|
||||
-- the requested one and 1/8192 in parallel
|
||||
local m = mon({ attack = 2, defense = 10, speed = 10, special = 10, hp = 8 })
|
||||
ok(Stats.isShiny(m.dvs), "fixture starts out shiny by luck")
|
||||
Shiny.decide(m, miss)
|
||||
ok(not Shiny.isShiny(m), "a miss clears an accidentally-shiny mon")
|
||||
eq(m.shiny, nil, "and clears the cached flag rather than storing false")
|
||||
end
|
||||
|
||||
do
|
||||
-- the odds are the number they claim. 1/1 must be every time; a large
|
||||
-- denominator must essentially never fire on a fixed stub.
|
||||
local saved = Shiny.ODDS_DENOM
|
||||
Shiny.setOdds(1)
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, function(_, hi) return math.random(1, hi) end)
|
||||
ok(Shiny.isShiny(m), "odds of 1 make every mon shiny")
|
||||
|
||||
Shiny.setOdds(0)
|
||||
eq(Shiny.ODDS_DENOM, 1, "a denominator below 1 is refused")
|
||||
Shiny.setOdds(saved)
|
||||
eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted")
|
||||
end
|
||||
|
||||
do
|
||||
-- stats must follow the DVs, and a full-health mon must stay full: a wild
|
||||
-- mon that appears at less than full HP is visible in the first frame
|
||||
local Data = require("src.core.Data")
|
||||
local haveData = type(Data) == "table" and type(Data.pokemon) == "table"
|
||||
and Data.pokemon.PIKACHU ~= nil
|
||||
if haveData then
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 },
|
||||
10, "PIKACHU")
|
||||
m.stats = Stats.calc(Data.pokemon.PIKACHU, 10, m.dvs, m.statExp)
|
||||
m.hp = m.stats.hp
|
||||
local before = m.stats.hp
|
||||
Shiny.decide(m, hit)
|
||||
ok(m.stats.hp >= before, "max HP tracks the raised DVs")
|
||||
eq(m.hp, m.stats.hp, "a full-health mon stays full after a restat")
|
||||
else
|
||||
io.write("note: no ROM-backed data; skipped the restat check\n")
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the recolour
|
||||
|
||||
do
|
||||
eq(type(ShinyPalette.forDex(6)), "table", "colours load for Charizard")
|
||||
local ch = ShinyPalette.forDex(6)
|
||||
eq(ch.slide.h, -136, "Charizard's hue slide is the Stadium value")
|
||||
eq(ch.slide.s, -6, "and its saturation step")
|
||||
ok(ShinyPalette.forDex(130).lut ~= nil,
|
||||
"Gyarados carries an explicit table, not a slide")
|
||||
ok(ShinyPalette.forDex(6).lut == nil, "and Charizard does not")
|
||||
end
|
||||
|
||||
do
|
||||
-- alpha is never touched, and a texture with nothing to do comes back
|
||||
-- unchanged rather than rebuilt
|
||||
local fn = ShinyPalette.transform(ShinyPalette.forDex(6))
|
||||
local px = string.char(222, 131, 123, 77) .. string.char(0, 0, 0, 0)
|
||||
local out = ShinyPalette.recolorTexels(px, fn)
|
||||
eq(#out, #px, "the texel string keeps its length")
|
||||
eq(out:byte(4), 77, "alpha survives the transform")
|
||||
eq(out:byte(8), 0, "and so does a fully transparent texel's alpha")
|
||||
end
|
||||
|
||||
do
|
||||
local fixture = loadfile(MOD .. "/tests/shiny_palette_fixture.lua")
|
||||
if not fixture then
|
||||
io.write("note: no colour fixture; skipped the cross-check\n")
|
||||
else
|
||||
local rows = fixture()
|
||||
local worst, bad = 0, 0
|
||||
for _, r in ipairs(rows) do
|
||||
local fn = ShinyPalette.transform(ShinyPalette.forDex(r[1]))
|
||||
local gr, gg, gb = r[2], r[3], r[4]
|
||||
if fn then gr, gg, gb = fn(r[2], r[3], r[4]) end
|
||||
local d = math.max(math.abs(gr - r[5]), math.abs(gg - r[6]),
|
||||
math.abs(gb - r[7]))
|
||||
if d > worst then worst = d end
|
||||
if d > 2 then bad = bad + 1 end
|
||||
end
|
||||
eq(bad, 0, ("%d colour pairs reproduce the shipped textures"):format(#rows))
|
||||
-- a unit or two of drift is the two languages' float rounding; more than
|
||||
-- that means the colour model itself has diverged
|
||||
ok(worst <= 2, ("worst channel drift is %d, within rounding"):format(worst))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the read side
|
||||
--
|
||||
-- The extraction writes NNNs.dsm beside NNN.dsm; this is the other half of
|
||||
-- that contract. Driven off a directory of real packs when one is given
|
||||
-- (--packs=DIR, e.g. the extract test's --out), because the interesting
|
||||
-- cases are a shiny pack that EXISTS and one that does not, and both have
|
||||
-- to be real files for the fallback to be worth testing.
|
||||
if args.packs then
|
||||
local Vp = { mod = { log = { warn = function() end, info = function() end } } }
|
||||
local loadedP = {}
|
||||
function Vp.require(name)
|
||||
if loadedP[name] == nil then
|
||||
loadedP[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(Vp)
|
||||
end
|
||||
return loadedP[name]
|
||||
end
|
||||
-- stand in for the mod's own reader, pointed at the pack directory
|
||||
function Vp.mod:read(rel)
|
||||
local name = rel:match("([^/]+)$")
|
||||
local fp = io.open(args.packs .. "/" .. name, "rb")
|
||||
if not fp then return nil end
|
||||
local b = fp:read("*a")
|
||||
fp:close()
|
||||
return b
|
||||
end
|
||||
local StadiumPack = Vp.require("StadiumPack")
|
||||
StadiumPack.DIR = "."
|
||||
|
||||
local normal = StadiumPack.load(6, false)
|
||||
local shiny = StadiumPack.load(6, true)
|
||||
ok(normal ~= nil, "the normal Charizard pack loads")
|
||||
ok(shiny ~= nil, "and so does the shiny one")
|
||||
if normal and shiny then
|
||||
eq(shiny.shiny, true, "the shiny model is flagged as such")
|
||||
eq(normal.shiny, nil, "and the normal one is not")
|
||||
ok(normal ~= shiny, "they are SEPARATE models, not one shared table")
|
||||
eq(#normal.prims, #shiny.prims, "same geometry")
|
||||
eq(normal.texCount, shiny.texCount, "same texture count")
|
||||
-- the point of the whole exercise: different pixels
|
||||
local differs = false
|
||||
for i = 1, math.min(#normal.textures, #shiny.textures) do
|
||||
if normal.textures[i].rgba ~= shiny.textures[i].rgba then
|
||||
differs = true
|
||||
break
|
||||
end
|
||||
end
|
||||
ok(differs, "and different texels")
|
||||
end
|
||||
|
||||
-- a species with no shiny pack must fall back rather than vanish: that is
|
||||
-- what an install from before rev 3 looks like
|
||||
local missing = StadiumPack.load(999, true)
|
||||
eq(missing, nil, "an out-of-range species is still nil")
|
||||
end
|
||||
|
||||
-- ------- end to end, through the engine's own constructor
|
||||
--
|
||||
-- The wrap is the whole feature: if Pokemon.new does not carry the verdict,
|
||||
-- nothing downstream has anything to draw. Exercised against the real
|
||||
-- constructor and the fixture dataset rather than a stub, because what is
|
||||
-- being tested is precisely that we hooked the thing the game calls.
|
||||
|
||||
do
|
||||
local okKit, T = pcall(require, "tests.modkit")
|
||||
local Data = okKit and T.fixtures and T.fixtures.load()
|
||||
local okPk, Pokemon = pcall(require, "src.pokemon.Pokemon")
|
||||
local species = Data and Data.pokemon
|
||||
and (Data.pokemon.PIKACHU and "PIKACHU"
|
||||
or next(Data.pokemon))
|
||||
if not (okKit and okPk and species) then
|
||||
io.write("note: no fixture dataset; skipped the end-to-end check\n")
|
||||
else
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
ShinyBattle.install()
|
||||
ShinyBattle.install() -- twice: the wrap must not stack
|
||||
|
||||
local saved = Shiny.ODDS_DENOM
|
||||
|
||||
Shiny.setOdds(1)
|
||||
local m = Pokemon.new(Data, species, 7)
|
||||
ok(Shiny.isShiny(m), "a mon built at odds 1 comes out shiny")
|
||||
eq(m.shiny, true, "and carries the flag")
|
||||
eq(m.hp, m.stats.hp, "and is at full health despite the restat")
|
||||
ok(Stats.isShiny(m.dvs), "and the ENGINE agrees it is shiny")
|
||||
|
||||
-- and at long odds it essentially never is: 400 draws at 1/8192 would
|
||||
-- fire about 5% of the time, so a single failure here is signal
|
||||
Shiny.setOdds(8192)
|
||||
local shinies = 0
|
||||
for _ = 1, 400 do
|
||||
if Shiny.isShiny(Pokemon.new(Data, species, 7)) then
|
||||
shinies = shinies + 1
|
||||
end
|
||||
end
|
||||
ok(shinies <= 2, ("400 mons at 1/8192 produced %d shinies")
|
||||
:format(shinies))
|
||||
|
||||
Shiny.setOdds(saved)
|
||||
end
|
||||
end
|
||||
|
||||
io.write(("\n%d passed, %d failed\n"):format(pass, fail))
|
||||
os.exit(fail == 0 and 0 or 1)
|
||||
@@ -1,12 +1,17 @@
|
||||
-- The Lua ROM extractor, against the Python packer that is its oracle.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR]
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR] [--mod=DIR]
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Defaults: the ROM under
|
||||
-- model_extract/baseroms/, the oracle in assets/stadium (whatever
|
||||
-- tools/stadium_pack.py last wrote there).
|
||||
--
|
||||
-- --mod points at the mod directory whose lib/ is under test. It exists for
|
||||
-- worktrees: the ROM and the packs are both gitignored, so a worktree has
|
||||
-- neither, and without this the test would load the SHARED checkout's
|
||||
-- modules while claiming to test the branch's.
|
||||
--
|
||||
-- ------- what this is for
|
||||
--
|
||||
-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of
|
||||
@@ -30,7 +35,7 @@ for _, a in ipairs({ ... }) do
|
||||
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
|
||||
end
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
|
||||
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
|
||||
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
|
||||
|
||||
@@ -46,6 +51,9 @@ function V.require(name)
|
||||
return loaded[name]
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
-- the mod's own directory, so a module that loads a data file finds it
|
||||
-- relative to the MOD rather than to wherever this was run from
|
||||
V.path = MOD
|
||||
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumBuild = V.require("StadiumBuild")
|
||||
@@ -79,6 +87,7 @@ if args.only then
|
||||
end
|
||||
|
||||
local checked, matched, missing, failed = 0, 0, 0, 0
|
||||
local shinyOk, shinyMissing, shinyBad = 0, 0, 0
|
||||
local firstBad = nil
|
||||
local t0 = os.clock()
|
||||
|
||||
@@ -93,6 +102,28 @@ for fileno = 0, StadiumRom.N_POKEMON - 1 do
|
||||
if args.out then
|
||||
local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb")
|
||||
if fp then fp:write(res.bytes) fp:close() end
|
||||
-- the shiny variant too, so the pair can be diffed out of process
|
||||
if res.shinyBytes then
|
||||
local sp = io.open(("%s/%03ds.dsm"):format(args.out, res.species), "wb")
|
||||
if sp then sp:write(res.shinyBytes) sp:close() end
|
||||
end
|
||||
end
|
||||
-- The shiny pack is the same DSM3 with recoloured texels, so it must be
|
||||
-- exactly as long and must actually differ. A species that produced none
|
||||
-- is counted rather than failed: it ships without a recolour and the
|
||||
-- runtime falls back to its normal model.
|
||||
if not res.shinyBytes then
|
||||
shinyMissing = shinyMissing + 1
|
||||
elseif #res.shinyBytes ~= #res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is %d bytes, normal is %d\n")
|
||||
:format(res.species, #res.shinyBytes, #res.bytes))
|
||||
elseif res.shinyBytes == res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is identical to normal\n")
|
||||
:format(res.species))
|
||||
else
|
||||
shinyOk = shinyOk + 1
|
||||
end
|
||||
local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species))
|
||||
if not want then
|
||||
@@ -119,8 +150,24 @@ io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, "
|
||||
.. "%d extractions failed (%.1fs)\n")
|
||||
:format(checked, matched, checked - matched - missing, missing,
|
||||
failed, os.clock() - t0))
|
||||
io.write(("shiny: %d recoloured, %d without a variant, %d malformed\n")
|
||||
:format(shinyOk, shinyMissing, shinyBad))
|
||||
|
||||
if matched == checked and failed == 0 and missing == 0 then
|
||||
-- NONE recoloured is a failure, not a quiet zero. It is what a missing or
|
||||
-- unfindable data/shiny_colors.lua looks like, and the first version of this
|
||||
-- test reported PASS through exactly that: 151 species built, every one of
|
||||
-- them without a shiny variant, and nothing in the output that read as
|
||||
-- wrong. A count of zero is now as loud as a malformed pack.
|
||||
if checked > 0 and shinyOk == 0 then
|
||||
io.write("NO SPECIES RECOLOURED -- data/shiny_colors.lua was not found\n")
|
||||
shinyBad = shinyBad + 1
|
||||
end
|
||||
|
||||
-- The oracle diff is the load-bearing assertion and is unchanged: the shiny
|
||||
-- pass must not have moved a single byte of the normal packs. The shiny
|
||||
-- counters are additional, and a malformed variant fails the run -- a pack
|
||||
-- of the wrong length would be read as a corrupt model at runtime.
|
||||
if matched == checked and failed == 0 and missing == 0 and shinyBad == 0 then
|
||||
io.write("PASS -- the Lua extractor reproduces the packer exactly\n")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Emit the mod's data/shiny_colors.lua from the validated Stadium tables.
|
||||
|
||||
PROVENANCE. data/shiny_colors.lua is 62KB of generated data and this is
|
||||
where it came from, so it is checked in even though its INPUTS are not:
|
||||
they live under .claude/shiny_stadium/ (the research tree: the transcribed
|
||||
Stadium colour table, the extracted texture pairs, the inventory that marks
|
||||
which textures are effects), and that path is gitignored along with
|
||||
everything else derived from the ROM.
|
||||
|
||||
So this will not run from a fresh clone, and it is not meant to -- the Lua
|
||||
it writes is the shipped artefact. It is here to record HOW the numbers were
|
||||
arrived at, and to be re-runnable by anyone who still has the research tree.
|
||||
|
||||
The values themselves are the Stadium games' own: a hue rotation in degrees
|
||||
plus saturation and lightness on a quantized -8..+8 scale at 12.5% a step.
|
||||
The column order in the source table is H, L, S -- hue, LIGHTNESS,
|
||||
saturation -- which was checked against the canonical pokeemerald palettes
|
||||
across all 146 sliding species before being trusted.
|
||||
|
||||
|
||||
Two kinds of entry, because Stadium itself has two kinds of shiny:
|
||||
|
||||
slide {h, l, s} the 146 species Stadium recolours by sliding the whole
|
||||
model in HSL. h is degrees; l and s are Stadium's
|
||||
-8..+8 steps at 12.5% each. Three numbers reproduce the
|
||||
whole model, so this is data-cheap and exact.
|
||||
|
||||
lut {from = to} the 5 species Stadium ships a genuine alternate texture
|
||||
for (Clefairy, Clefable, Jigglypuff, Wigglytuff,
|
||||
Gyarados). No slide can express those -- Jigglypuff's
|
||||
body must stay pink while its irises rotate to green --
|
||||
so they carry an explicit colour mapping, sampled from
|
||||
the verified texture pairs. Exact by construction, and
|
||||
only the colours that actually change are listed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
SS = r"C:\Users\blcha\Desktop\DRAMATIC_SHAPE\pokemon-gen1-recomp-project\mods\DramaticShapeVoxelMod\.claude\shiny_stadium"
|
||||
OUT = r"C:\Users\blcha\Desktop\DRAMATIC_SHAPE\pokemon-gen1-recomp-project\mods\DramaticShapeVoxelMod\.claude\worktrees\shiny-system\data\shiny_colors.lua"
|
||||
|
||||
vals = json.load(open(os.path.join(SS, "stadium_shiny_values.json"), encoding="utf-8"))
|
||||
V = vals["species"]
|
||||
|
||||
inv = json.load(open(os.path.join(SS, "texture_inventory.json"), encoding="utf-8"))
|
||||
models = inv["models"] if isinstance(inv, dict) and "models" in inv else inv
|
||||
if isinstance(models, dict):
|
||||
models = list(models.values())
|
||||
kind = {}
|
||||
for m in models:
|
||||
dex = str(m.get("dex") or m.get("species") or "").zfill(3)
|
||||
for t in m.get("textures", []):
|
||||
kind[(dex, int(t.get("index", -1)))] = t.get("kind", "body")
|
||||
|
||||
TEX = os.path.join(SS, "textures")
|
||||
slugs = {d.split("_")[0]: d for d in os.listdir(os.path.join(TEX, "normal"))}
|
||||
|
||||
|
||||
def lut_for(dex):
|
||||
"""Distinct colour mapping over this species' BODY textures only.
|
||||
|
||||
fx textures are excluded exactly as the build excluded them: a shiny
|
||||
Gyarados has shiny scales and an ordinary Hyper Beam.
|
||||
"""
|
||||
d = slugs[dex]
|
||||
pairs = {}
|
||||
for fn in sorted(os.listdir(os.path.join(TEX, "normal", d))):
|
||||
idx = int(fn.replace("tex_", "").replace(".png", ""))
|
||||
if kind.get((dex, idx), "body") != "body":
|
||||
continue
|
||||
a = Image.open(os.path.join(TEX, "normal", d, fn)).convert("RGBA")
|
||||
b = Image.open(os.path.join(TEX, "shiny", d, fn)).convert("RGBA")
|
||||
if a.size != b.size:
|
||||
continue
|
||||
pa, pb = a.load(), b.load()
|
||||
for y in range(a.size[1]):
|
||||
for x in range(a.size[0]):
|
||||
ca, cb = pa[x, y], pb[x, y]
|
||||
if ca[3] < 8:
|
||||
continue
|
||||
pairs.setdefault(ca[:3], cb[:3])
|
||||
return {k: v for k, v in pairs.items() if k != v}
|
||||
|
||||
|
||||
lines = []
|
||||
w = lines.append
|
||||
|
||||
w("-- Shiny colours for the 151 Stadium models, as the Stadium games define")
|
||||
w("-- them. GENERATED -- do not hand-edit. The colour model is documented in")
|
||||
w("-- the header of lib/ShinyPalette.lua.")
|
||||
w("--")
|
||||
w("-- Stadium does not ship a second set of textures for a shiny Pokemon. It")
|
||||
w("-- slides the colours it already has in HSL: a hue rotation in degrees,")
|
||||
w("-- plus saturation and lightness on a quantized -8..+8 scale where one")
|
||||
w("-- step is 12.5% (so +-8 is +-100%, exactly GIMP's Hue-Saturation range --")
|
||||
w("-- s = -8 is full greyscale, l = +8 is white).")
|
||||
w("--")
|
||||
w("-- FIVE SPECIES ARE DIFFERENT. Clefairy, Clefable, Jigglypuff, Wigglytuff")
|
||||
w("-- and Gyarados get a genuine alternate texture in Stadium, because no")
|
||||
w("-- single slide can produce their shiny: Jigglypuff's body must stay pink")
|
||||
w("-- while its irises rotate to green, and one rotation moves both or")
|
||||
w("-- neither. Those five carry `lut` -- an explicit before/after colour")
|
||||
w("-- mapping sampled from the verified texture pairs, listing only the")
|
||||
w("-- colours that actually change. A colour absent from the table is left")
|
||||
w("-- exactly as it was.")
|
||||
w("--")
|
||||
w("-- hueRange is the min/max hue a NON-shiny nicknamed mon can slide to in")
|
||||
w("-- Stadium (derived from the trainer ID and the nickname). Carried for")
|
||||
w("-- reference; nothing reads it today.")
|
||||
w("")
|
||||
w("return {")
|
||||
|
||||
n_slide = n_lut = n_entries = 0
|
||||
for dex in sorted(V):
|
||||
e = V[dex]
|
||||
hr = e["hue_range"]
|
||||
w(" [%d] = {" % int(dex))
|
||||
w(' name = "%s",' % e["name"])
|
||||
w(" hueRange = { min = %d, max = %d }," % (hr["min"], hr["max"]))
|
||||
if e["special_texture"]:
|
||||
n_lut += 1
|
||||
lut = lut_for(dex)
|
||||
n_entries += len(lut)
|
||||
w(" -- Stadium ships a real alternate texture for this one; %d"
|
||||
% len(lut))
|
||||
w(" -- colours move, every other colour stays exactly as it was.")
|
||||
w(" lut = {")
|
||||
row = []
|
||||
for src in sorted(lut):
|
||||
dst = lut[src]
|
||||
row.append("[0x%02X%02X%02X]=0x%02X%02X%02X,"
|
||||
% (src[0], src[1], src[2], dst[0], dst[1], dst[2]))
|
||||
if len(row) == 4:
|
||||
w(" " + " ".join(row))
|
||||
row = []
|
||||
if row:
|
||||
w(" " + " ".join(row))
|
||||
w(" },")
|
||||
else:
|
||||
n_slide += 1
|
||||
h = e["hsl"]
|
||||
w(" slide = { h = %d, l = %d, s = %d }," % (h["h"], h["l"], h["s"]))
|
||||
w(" },")
|
||||
|
||||
w("}")
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
open(OUT, "w", encoding="utf-8").write("\n".join(lines) + "\n")
|
||||
print("wrote", OUT)
|
||||
print("slide species:", n_slide, " lut species:", n_lut,
|
||||
" lut entries:", n_entries)
|
||||
print("bytes:", os.path.getsize(OUT))
|
||||
Reference in New Issue
Block a user