Merge pull request #143 from DramaticShape/shadow-toggle

Shadow toggle
This commit is contained in:
DramaticShape
2026-08-08 14:31:11 -04:00
committed by GitHub
43 changed files with 9087 additions and 373 deletions
+295
View File
@@ -1,5 +1,92 @@
# 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.
**SHINY ODDS**, on the DRAMATIC SHAPE menu itself rather than in one of
its four categories -- those are the diorama, the fights, what the look
costs and the headset, and an encounter rate is none of them. The row
reads `1:8192` and halves down to `1:1`, so every rung is exactly twice
as often as the one above it, and `1:8192` is both the default and the
fallback for an unreadable options file: the mod's default is the games'
own rate, not a buff. The number is the truth rather than an
approximation of it, because a missed roll also clears a mon that landed
on the pattern by luck -- without that, every setting would be itself
and 1/8192 in parallel, and no setting could ever be rarer than 8192.
## 1.8.0
### Added
@@ -42,6 +129,20 @@
EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
shakes the roll answers are the rocks the ball plays on the ground.
**Running out, and staying out of the way.** Under FULL an empty bag
does not hand the fight back to the classic menu -- there is no fight to
hand back, since a Let's Go wild has no Pokemon of yours in it and a foe
that never takes a turn, so that menu would offer a FIGHT that cannot
happen. The encounter keeps its own screen: the seat holds, the Pokemon
stands there, the readout says NO BALLS LEFT, and RUN is the way out.
Throwing your last ball lands in the same place rather than ending the
session. And the scripted catch tutorials -- the VIRIDIAN CITY old man,
and Yellow's PROF.OAK catching the PIKACHU -- are left alone at every
rung: they are cutscenes wearing a battle's clothes, where the cursor,
the bag and the throw are all scripted and nobody keeps the Pokemon, so
they play exactly as the original does with no capture screen, no held
camera and no experience.
**What it stands on.** The outcome is exactly a Gen 1 ball throw: same
catch math (status, HP and ball factors intact), same outcome texts,
same caught flow -- dex page, nickname, box overflow -- and a missed
@@ -50,6 +151,200 @@
depth-capable driver, no headset); anywhere it cannot stand, balls
quietly take the engine's classic toss.
- **SHADOWS: a row that stands the sun's pass down.** Cast shadows are the
most expensive thing the mode draws after the geometry -- the whole world
rendered a second time from the light, every time the view or anybody in
it moves -- and on a phone or an old laptop that is the difference between
the diorama running and the diorama stuttering. ON by default, because a
world where a building throws nothing reads as flat however many voxels it
is made of. OFF means off rather than "fall back": the flat decal drop
shadows are the stand-in for a machine that WANTED shadows and could not
have them, so they stay down too, and the forest's light shafts go with
them (the beams are lit by the sun's own map). FULL neither sets the row
nor takes it away, on the same reasoning as AA -- what the look costs is
the player's question, not a preset's.
### Added
- **SELECT on any row of the mod's menus explains what it does.** Every
setting here has carried a paragraph of help since it was written -- it is
handed to the mod manager with the rest of the schema -- and nothing in the
engine has ever drawn one. It could not: a row is a label and a value, and
no options row anywhere has room for a third thing. So a row says what it
IS on one line and what it is SET TO on the next, and SELECT says what that
MEANS, which is the question RENDER DIST or 2D-3D B cannot answer in
eighteen characters however the label is worded.
It opens the game's own dialogue box -- drawn with the ROM's own border
glyphs, anchored to the bottom of the screen where this game has always put
text, and only as tall as the sentence it holds, so the row being asked
about is still visible above it. A, B, START and SELECT all close it, SELECT
included: it is the button somebody who just pressed it will reach for. The
bottom line of every one of the mod's menus now reads `B BACK SEL HELP`,
because a binding nobody knows about is worth nothing.
Every description is ONE SENTENCE, and the whole of it is on screen at once.
The long paragraphs these grew from were written for a reader that never
existed, and they read as documentation rather than as an answer; a box you
have to scroll is a worse reply to "what does this do" than a shorter
sentence is. Both properties are tested rather than trusted -- a description
that gains a second sentence, or that outgrows its box, fails the suite.
VOXEL, T-SHIFT and STADIUM ROM got sentences of their own to go with the
thirteen settings: the first two are the engine's row descriptors with
nowhere to keep one, and the third is an action rather than a setting.
The suite also checks every character of every description against the ROM's
real charmap, because Font.encode answers a glyph it does not have with a
SPACE and a one-time console warning -- so a curly quote pasted in from
somewhere would blank a word on screen and say nothing about it.
### Changed
- **The settings live on menus of their own now, behind one red row at the
top of OPTIONS.** This mod had grown to fourteen rows on the engine's
list, spliced in as one block. OPTIONS shows four boxes at a time, so that
was four screens of scrolling inside a list that already carried twenty
engine rows, and finding SHADOWS meant knowing it was in there somewhere
past the wireframe and the horizon bend.
What is on OPTIONS now is `DRAMATIC SHAPE..`, and it leads the list --
a mod that replaces the look of the whole game should not make the player
scroll to find out where its settings went, least of all past the engine
rows it has quietly taken away. It opens VOXEL and T-SHIFT, which came off
the engine's list with it, and four categories: **3D WORLD** (V-GRID,
V-CURVE, RENDER DIST, WATER, DAYTIME), **BATTLES** (3D-BTL, BACK SPRITES,
LET'S GO), **PERFORMANCE** (FOREST FX, SHADOWS, AA) and **VR** (VR,
SMOOTH TURN). STADIUM ROM stays on that top-level menu, last: it is
one-time setup rather than a setting, and somebody who has been told to
import a cartridge should find the row where the mod begins, not two
levels down a category they have no reason to open until it has worked.
The split is not a new opinion: it is the `full` flag each row already
carried. `full` marks a row the FULL preset does not take away, and the
reason written beside each one was always the same -- this is a question
about the HARDWARE, or about the GAME, not a knob on the diorama FULL is a
preset for. So 3D WORLD is exactly the rows FULL owns, and needs no rule
to disappear under it: every child filters itself out and an empty category
is not offered. Under FULL the menu is four rows on one screen with no
scroll arrow. The same rule retires VR where there is no VR to have.
**Nothing you had set has moved.** Every setting keeps its stored key, its
ladder and its row id, so `options.lua` is byte-identical across the
upgrade for a player who changes nothing -- and the hotkeys are untouched,
which is what makes the nesting affordable: 3, 5, 6, 7, 8 and 9 still put
every buried row one keypress away. The mod manager's own page still lists
all thirteen settings flat, now in category order.
The row is drawn in red, which is a palette zone rather than a color:
`setColor` cannot tint this text, because the glyph atlas is black ink and
LOVE tints multiplicatively, and because the palette shader keys on the red
channel alone and would send a red pixel to the lightest slot. What the
zone changes is which color the shade the text was drawn in comes out as.
It is MEWMON -- the palette the OPTIONS menu already wears -- copied with
only the ink slot replaced, so the paper under the row is the same white as
the row above it in all three ROMs, and the band covers the two text lines
alone rather than the cursor and the box borders beside them. SGB INV
reverses a palette, so there the red starts in the other slot and still
lands on the ink; OG, OG INV and CLASSIC substitute their own tables
outright, and the row simply draws monochrome, which is what asking for a
screen with no colors in it should get.
### Fixed
- **A setting that pins another one now pins it from wherever it was
changed.** 3D-BTL holds BATTLE LAYOUT at OG while a fight can be staged on
the map, and FULL holds DAYTIME at SYNC while it owns that row. Both pins
used to be a side effect of the options-rows hook, which every step on the
OPTIONS menu happened to rerun -- so they fired whether or not the step was
the one that mattered, and nothing had to name them. A step made on the
mod's own menus reruns no hook, so the pinning is a function now, and the
hook, the menus and the mod manager's page all ask for it.
- **An open OPTIONS menu notices a change made on a menu pushed over it.**
The rebuild that keeps the row list honest compared the voxel level and the
two battle switches across one call of `update`. The stack ticks its top
state only, so a step taken on one of the mod's own menus happens while
OPTIONS is suspended: both halves of that comparison were read after the
fact and always agreed, and OPTIONS came back still showing a BATTLE LAYOUT
row that no longer belonged there. The signature is held on the menu and
stamped where the rows are built, which is the thing it is a signature of.
- **A building's back no longer wears its own front door.** Every voxelized
building is its drawing extruded straight through the footprint, so the
far wall is the facade again -- and read from behind, the facade mirrored:
a door on the back of every house, a POKe sign readable backwards on
every Center, MART on every mart and GYM painted across the back of every
gym. Those tiles are now named per tileset (`frontOnly` in
`data/voxel_heights.lua` -- the doorways, the hanging shop signs and the
gyms' lettering) and every cell wearing one takes the art of the nearest
ordinary cell beside it in the same tile row instead. The donor is picked
per RUN, so a two-tile doorway comes out as two tiles of the same wall
rather than borrowing left from one side and right from the other, and
between the two neighbours the one that row uses more often wins -- which
is what reaches past a gable's sloped corner for the wall behind it. At
the base course the donor lifts one row with the model, because the
drawing's last row is the black threshold a door stands on and the wall
beside it does not paint; without that the doorway kept its own foot and
the back's bottom course had a notch in it. Windows are deliberately left
alone: a back wall with windows is right. The generic volume path folds
the same drawing up all four sides and had the same bug on its back AND
its flanks, so it takes the same substitution -- only the south face,
which IS the drawing, keeps every tile of it.
- **B now actually runs from capture mode -- and A throws, and L/R switch
balls.** The capture session read its button presses on the RENDER clock,
along with everything else it does per frame. Button edges do not survive
there: the engine rebuilds the edge table once per fixed logic step and
runs all of a frame's steps BEFORE the render-clock hooks, so any frame
carrying more than one step had already thrown the press away before
anything looked at it. That is not a rare race -- it is every press below
60fps, which is exactly where a 3D battle lives, so these buttons were
reliably dead on the machines that most needed them and fine on a 144Hz
one. They are read on the logic step now, through the engine's own
input.step seam, and taken rather than peeked so a press the capture used
does not also page the message it just queued.
- **No more grass smeared across the top of a LET'S GO throw.** The capture
seat handed BattleScene its pitch as the DEPRESSION below level, and the
one thing that reads it -- the camera-ward pull the grass and flowers are
drawn with -- measures angles off STRAIGHT DOWN, the complement. So a seat
looking nearly level read as the top-down end of the ladder, where the pull
is longest: 46 world pixels of bias, handed to a camera standing 46 world
pixels behind the player. The pull is a shove along each vertex's own eye
ray -- a pure depth bias while it is shorter than the range, and past that
it carries geometry THROUGH the lens, where the projection turns inside out
and a single tuft at the eye lands smeared across the frame. That was the
greenery hanging over the top of a capture shot on any route or street with
grass rows beside it. The seat now speaks the same convention the battle's
own rig does, and the vertex stage clamps the pull to half the range to the
eye besides -- so no camera standing this close can be smeared by a bias
again, in a capture, a fight, or first person.
- **The grass moves during a staged battle.** The wind is switched on around
the free-roam pass's grass draws and off again after them, and the battle
pass -- which draws the same tufts, on the same map, from its own camera --
never switched it on: the uniform sat at the per-frame default, which means
no wind, so a field that was moving one frame before the encounter went dead
still for the whole fight and started again when it ended. A fight is staged
on the MAP, in that place's own weather and light; a frozen field was the one
thing reading as a photograph of it rather than the place. No walker-contact
push comes with it -- that is somebody stepping through the grass, and the
two mons stand still on their own tiles.
- **The bottom of the frame no longer bites a row out of the scenery.**
RENDER DIST cut the world to where the frame's rays land on the GROUND,
and the ground is not what the picture is made of: a tree at the bottom of
the screen has its feet south of the row its top is seen on, because the
bottom edge's ray is still coming down as it passes them. The cut is by
column -- deliberately, so it never takes the tops off trees -- so a tree
whose base fell one pixel outside lost its whole height at once, and the
last row of forest along the bottom of the frame was cut through with the
ground behind it showing. The south edge is now walked back down that same
ray by the tallest thing that can stand on it (about a tile and a half at
35 degrees, four tiles at 50, eleven at 75), plus a tile of slack so a hard
edge is never decided by a rounding. FIT carries it too: it is a correction
to the honest answer, not margin around it.
## 1.7.1
### Added
+1
View File
@@ -24,6 +24,7 @@ menu.
| `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models |
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
| the **SHADOWS** options row | ON / OFF — real cast shadows, thrown by rendering the whole scene a second time from the sun, so buildings, trees, ledges and people shadow whatever they land on: walls, roofs, each other. The most expensive pass in the mode after the geometry itself, and the first thing to turn off on a phone or an old machine. **OFF** is no shadow at all — the flat drop shadows under characters included — and the forest's light shafts go with it, since the beams are lit by the sun's own map. **FULL** leaves it alone |
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
File diff suppressed because it is too large Load Diff
+26
View File
@@ -2976,6 +2976,32 @@ return {
},
},
-- Tiles that are FRONT art: they belong on the drawn facade and nowhere
-- else. A building's back is the same drawing extruded straight through
-- the footprint (lib/Buildings.lua `model`, and the volume path's north
-- face in lib/ChunkMesher.lua), so without this every house wears a
-- second door on its far wall and every Center a POKe sign readable
-- backwards. A cell wearing one of these ids takes the art of the
-- nearest ordinary cell beside it in the same tile row instead -- left or
-- right, whichever tile that row uses more, which is what reaches PAST a
-- gable's sloped corner for the wall behind it.
--
-- Windows are deliberately absent: a back wall with windows is right.
-- These are the doorways, the hanging shop signs and the painted GYM
-- lettering -- the three things a facade has that its back does not.
-- Ids are per tileset, indexing that tileset's own atlas.
frontOnly = {
-- doorway 11/12 over 27/28 (27 is the tileset's own doorTile); the
-- POKe (66/67) and MART (68/69) signs over their bracket row 74; the
-- GYM lettering 47/63 painted across the gyms' upper course.
OVERWORLD = { 11, 12, 27, 28, 47, 63, 66, 67, 68, 69, 74 },
-- the Indigo Plateau and Victory Road entrances: the same doorway
-- block, drawn into the cliff face.
PLATEAU = { 11, 12, 27, 28 },
-- the Safari Zone gate's double door, 42/43 over 58/59.
FOREST = { 42, 43, 58, 59 },
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
-- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its
-- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
+32 -1
View File
@@ -579,7 +579,14 @@ function BattleScene.render(state, arena, textures, token)
local cap = BattleScene.capture
if cap and cap.rig then
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
-- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
-- BattleCam.rig hands back -- the only thing downstream reads it is the
-- grass and flower pull below. A seat that declines to say stands in
-- for a near-LEVEL one rather than a top-down one, which is what every
-- staged seat actually is: the pull grows toward straight down, and a
-- default that guessed the wrong end of that would spend tens of world
-- pixels of bias on a camera standing two cells from its subject.
if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), fh end
end
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
@@ -729,6 +736,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
@@ -736,11 +749,29 @@ function BattleScene.render(state, arena, textures, token)
-- pull is also what keeps a tuft from z-fighting the floor it stands on
local pull = VoxelScene.pull(math.max(pitch, 0.05))
if not discs then
-- and the WIND blowing through it, exactly as the free-roam pass
-- switches on around its own grass draws (VoxelScene). Without this
-- the uniform sits at the per-frame default beginScene sends -- zero,
-- meaning "no wind" -- and the tall grass a fight is standing in goes
-- dead still for the length of the battle while the same tufts one
-- frame earlier, and one frame after, were moving. A staged fight is
-- shot on the MAP, in that place's own weather and light; a frozen
-- field is the one thing that reads as a photograph of it rather than
-- the place itself.
--
-- No contact point goes with it (grassWind's px/pz are left nil, which
-- sends the far-away sentinel): that push is a WALKER parting the grass
-- they are stepping through, and there is nobody walking here -- the
-- two mons stand still on their own tiles for the whole shot.
Voxel3D.grassWind(true)
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
-- off again before the flowers, which are not grass and have no sway
-- of their own -- the same order the free-roam pass draws them in
Voxel3D.grassWind(false)
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
ShadowMap.snug(nil))
+120 -5
View File
@@ -134,9 +134,112 @@ local function profile()
end
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
local frontSets = {} -- tileset id -> { [tile] = true } or false
-- The tileset's front-only tiles as a set (data/voxel_heights.lua
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
-- on a facade and on no other face of the same building. nil when the
-- tileset names none, which is every indoor one.
function Buildings.frontOnly(tilesetId)
local hit = frontSets[tilesetId]
if hit == nil then
local s = profile()
local list = s and s.frontOnly and s.frontOnly[tilesetId]
if list then
hit = {}
for _, id in ipairs(list) do hit[id] = true end
else
hit = false
end
frontSets[tilesetId] = hit
end
return hit or nil
end
-- ------------------------------------------------------------------ read --
-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
-- the front shows. The facade extrudes straight through the footprint, so
-- the far wall is the drawing again -- and read from behind it is the
-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
-- wearing one takes the art of an ordinary cell beside it in the same tile
-- row.
--
-- The donor is chosen per RUN of front-only cells, not per cell, so a
-- two-tile doorway comes out as two tiles of the SAME wall rather than
-- borrowing left from one side and right from the other. Between the two
-- neighbours the one whose tile the row uses more often wins, which is
-- what reaches past a gable's sloped corner (a 4x2 house draws its door
-- against the slope: the corner is unique to the row, the wall beside it
-- is not) for the wall the back should actually wear.
--
-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
-- "unchanged"; nil when the drawing has no front-only tile at all, which is
-- most of them.
local function backMap(tiles, bw, bh, W, inside, frontOnly)
if not frontOnly then return nil end
local donor, any = {}, false
for r = 1, bh do
local row = tiles[r]
local freq = {}
for c = 1, bw do
local id = row[c]
if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
end
local c = 1
while c <= bw do
if frontOnly[row[c]] then
local c1 = c
while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
local l, rt = c - 1, c1 + 1
local pick = nil
if l >= 1 and rt <= bw then
pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
elseif l >= 1 then
pick = l
elseif rt <= bw then
pick = rt
end
-- a row that is front-only end to end has no donor; it keeps its
-- own art rather than inventing one
if pick then
for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
any = true
end
c = c1 + 1
else
c = c + 1
end
end
end
if not any then return nil end
local back = {}
for cell, dc in pairs(donor) do
local r, c = math.floor(cell / bw), cell % bw
for oy = 0, 7 do
local sy = r * 8 + oy
for ox = 0, 7 do
local i = sy * W + c * 8 + ox
local j = sy * W + dc * 8 + ox
-- The donor must be DRAWN, or the substitution would hand the wall
-- a texel from outside the silhouette. One row up is tried first,
-- because the drawing's last row is the black threshold the
-- building stands on: the doorway paints it (a door sits on the
-- ground) and the wall beside it does not, so at the base course
-- the same row of the donor column is off the shape. The model
-- lifts that column's foot by exactly one row for the same reason
-- (see `at`), and lifting the donor with it is what makes the back
-- wall's bottom course continuous.
if not inside[j] then j = j - W end
if j >= 0 and inside[j] then back[i] = j end
end
end
end
return back
end
-- Composite the template out of the atlas and flood the silhouette in from
-- the border. Returns flat arrays indexed y * W + x.
--
@@ -149,7 +252,7 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
-- is built from the complete drawing and the tower rises to its real
-- height instead of folding as two half-buildings.
local function read(t, data, perRow)
local function read(t, data, perRow, frontOnly)
local tiles = t.tiles
if t.topRows then
tiles = {}
@@ -244,7 +347,8 @@ local function read(t, data, perRow)
end
end
end
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
back = backMap(tiles, bw, bh, W, inside, frontOnly) }
end
-- --------------------------------------------------------------- measure --
@@ -1034,6 +1138,11 @@ local function model(sp, pr, t)
local T = {}
for x = 0, W - 1 do T[x] = ytop - top[x] end
-- the back layer's texel: the drawing again, minus what only the front
-- may wear (see backMap)
local back = sp.back
local function backOf(i) return (back and back[i]) or i end
local function at(x, y, z)
if x < 0 or x >= W then return nil end
local tx = T[x]
@@ -1070,7 +1179,11 @@ local function model(sp, pr, t)
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
local sy = ground - 1 - y
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
return sy * W + x
-- z < 0 is the awning's NORTH end: same substitution the wall
-- behind it makes, so a band that carries a sign does not carry
-- it round the back
local i = sy * W + x
return z < 0 and backOf(i) or i
end
return nil
end
@@ -1092,7 +1205,7 @@ local function model(sp, pr, t)
if pr.recess[i] then return nil end
return i
end
if z == 0 then return i end
if z == 0 then return backOf(i) end
return pr.interior[i]
end
@@ -1363,7 +1476,8 @@ function Buildings.build(S, map, data, perRow)
-- detector they stood as a second half-building.
models[key] = {}
else
local sp = read(t, data, perRow)
local sp = read(t, data, perRow,
Buildings.frontOnly(tileset.id))
local pr = measure(sp, t)
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
end
@@ -1474,6 +1588,7 @@ end
function Buildings.invalidate()
spec = nil
models = {}
frontSets = {}
end
return Buildings
+203 -38
View File
@@ -285,20 +285,72 @@ end
-- ball at the bottom of the frame, the throw a straight shot up the
-- middle. Handed to BattleScene.render through the capture table's `rig`;
-- everything downstream (pins, cards, sun, fov) is camera-generic.
-- ------- and how far back the WORLD lets it stand
--
-- SEAT_BACK is what the shot wants. It is not always available: this seat
-- is low (SEAT_UP is 13 world pixels, under a single cell) where the
-- battle's own rig stands at 37.9, and it is planted three cells behind
-- the player wherever the encounter happened -- which on a hedged route
-- like ROUTE 6 is inside the hedge. The eye then looks out from within
-- the foliage and the scenery it is standing in hangs across the top of
-- the frame, over the horizon and the distant rooftops both.
--
-- ThirdPerson's boom answers exactly this question for the free-roam
-- camera, so it answers it here rather than being restated: march back
-- from the player's cell and stop at the first thing the eye may not be
-- inside, keeping its PAD. Its rule is the right one for a seat this low,
-- too -- terrain height always blocks, and anything BUILT on a cell
-- blocks below head height, so a kerb is passed over and a hedge is not.
--
-- Pulling in brings the foe closer in a fixed field of view, which is a
-- composition the throw already handles: the ring, the collision and the
-- drag are all measured from the live shot, so they follow the seat.
local SEAT_MIN_BACK = 20 -- never nearer than this: the held ball hangs
-- at the player's own cell and has to stay in
-- front of the eye
local function seatBack(px, pz, ax, az, eyeY)
local ok, ow = pcall(function() return game().overworld end)
if not (ok and ow) then return SEAT_BACK end
local ThirdPerson = V.require("ThirdPerson")
local okR, reach = pcall(ThirdPerson.reach, ow, { px, eyeY, pz },
-ax, 0, -az, SEAT_BACK)
if not (okR and reach) then return SEAT_BACK end
return math.max(SEAT_MIN_BACK, math.min(SEAT_BACK, reach))
end
local function captureRig(arena, groundY)
local ex, ez = arena.enemy[1], arena.enemy[2]
local px, pz = arena.player[1], arena.player[2]
local ax, az = ex - px, ez - pz
local l = math.sqrt(ax * ax + az * az)
if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end
local eyeY = groundY + SEAT_UP
local back = seatBack(px, pz, ax, az, eyeY)
local cam = {
eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK },
eye = { px - ax * back, eyeY, pz - az * back },
focus = { ex, groundY + 8, ez },
fov = SEAT_FOV,
}
-- the pitch VoxelScene.pull wants: how far below level the seat looks
local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l)
return cam, math.max(pitch, 0.05), SEAT_FRAME
-- The pitch VoxelScene.pull wants, and it is the angle off STRAIGHT DOWN
-- -- the convention Voxel.angle keeps and BattleCam.rig hands back
-- (atan2(horizontal run, height over the focus)). This used to answer the
-- DEPRESSION below level instead, which is that angle's complement, and
-- the two are as far apart as a camera can be: a seat looking nearly
-- level read as 0.06 radians, which is what the pull formula means by
-- LOOKING STRAIGHT DOWN, so the grass and the flowers were pulled 46
-- world pixels camera-ward instead of 6.
--
-- 46 is the whole distance this seat stands behind the player. The pull
-- is a bias along each vertex's own eye ray, harmless while it is short
-- of the range -- and past it, it drags geometry THROUGH the lens, where
-- the projection turns inside out and one tuft of grass at the eye smears
-- across the frame. That was the greenery hanging over the top of a
-- capture shot, on a route or a city street with grass rows either side.
-- (Voxel3D's vertex stage now clamps the pull to half the range as well,
-- so no camera this close can be smeared by a bias again.)
local pitch = math.atan2(back + l, math.max(1e-3, SEAT_UP - 8))
return cam, pitch, SEAT_FRAME
end
-- The ring in GB space, centred on the CREATURE. The pinned mark is the
@@ -913,6 +965,19 @@ local function drawGB(battle)
local g = love.graphics
g.push("all")
-- The empty hand: no ring (nothing will be graded) and no ball readout
-- (there is no ball) -- just what happened and the way out. On the two
-- rows the aim HUD already uses, NOT side by side on one: at the GB font
-- these two strings are ~110 and ~56 pixels wide in a 160 pixel frame,
-- so a single row draws the second plate straight over the end of the
-- first ("OUT OF BALL|A/B:RUN").
if S.phase == "aim" and S.empty then
label("NO BALLS LEFT", 8, 119)
label("A/B:RUN", 158, 131, "right")
g.pop()
return
end
if S.phase == "aim" or S.phase == "flight" then
local cx, cy, outer = ringGeometry(shot)
local rho = ringRho(S.ringT)
@@ -945,7 +1010,10 @@ local function drawGB(battle)
-- enough at the GB font to run under the B:BACK plate opposite
ballGlyph(8, 137, S.ballId)
label(("%s x%d"):format(name:gsub("%s*BALL%s*", ""), count), 15, 131)
label("B:BACK", 158, 131, "right")
-- and what B does, which is not the same thing in both modes: under
-- FULL there is no classic menu behind the throw to back out TO, so B
-- is the encounter's RUN and the label has to say so
label(S.fullWild and "B:RUN" or "B:BACK", 158, 131, "right")
if S.canSwitch then label("L/R:BALL", 15, 119) end
-- the wind-up readout: sparks orbit the ball as it charges, dim and
@@ -1011,6 +1079,12 @@ CatchThrow.pickBall = function()
return owned[1]
end
-- The seat, named for the suite: it is a pure function of the arena and the
-- floor height, so the framing and -- the reason it is reachable at all --
-- the PITCH convention it hands BattleScene can both be asserted without a
-- battle, a canvas or a game.
CatchThrow._rig = captureRig
-- ------- session lifecycle
-- The capture table BattleScene consults: one shape, installed by the
@@ -1042,10 +1116,12 @@ end
-- opts: consumed (the bag already took the ball), safari (safari flow),
-- canSwitch (aim-time ball cycling), declinable (B backs out / runs),
-- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees)
-- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees),
-- empty (ballId is nil -- the out-of-balls hand, see rearm below)
function CatchThrow.begin(battle, ballId, opts)
if S then return false end
opts = opts or {}
local empty = opts.empty or ballId == nil
local ok = pcall(function()
local arena, groundY = OB().arenaInfo()
local shot = OB().shot()
@@ -1056,8 +1132,9 @@ function CatchThrow.begin(battle, ballId, opts)
ballId = ballId,
safari = opts.safari or false,
fullWild = opts.fullWild or false,
empty = empty,
consumed = opts.consumed or false,
canSwitch = opts.canSwitch or false,
canSwitch = (opts.canSwitch and not empty) or false,
declinable = opts.declinable ~= false,
phase = "aim",
clock = 0, t = 0, ringT = 0,
@@ -1072,6 +1149,7 @@ function CatchThrow.begin(battle, ballId, opts)
handGB = { 80, 110 },
}
ball.pos = handPos()
ball.visible = not empty
S.body = { r = 10, yOff = 8, hh = 8 } -- until a measurement lands
measureBody()
end)
@@ -1080,7 +1158,7 @@ function CatchThrow.begin(battle, ballId, opts)
return false
end
battle.phase = CatchThrow.PHASE
CatchThrow.lastBall = ballId
if ballId then CatchThrow.lastBall = ballId end
-- the capture seat, HELD: still drops the drift, the steer and the lift
-- inside the rig itself, and OverworldBattle's per-frame steerable gate
-- closes every input that could move it. The player's own steered angle
@@ -1089,13 +1167,27 @@ function CatchThrow.begin(battle, ballId, opts)
return true
end
-- the next ball into the hand, without tearing the seat down: FULL's
-- "always in throw mode" between one throw and the next
-- The next ball into the hand, without tearing the seat down: FULL's
-- "always in throw mode" between one throw and the next.
--
-- A NIL ball is not a failure to rearm -- it is the empty hand, and it is
-- still capture mode. Running out mid-encounter does not hand the fight
-- back to the classic menu (there is no fight: a Let's Go wild has no
-- player Pokemon in it and the foe never takes a turn, so the menu would
-- offer a FIGHT that cannot happen against a foe that cannot answer).
-- The seat holds, the Pokemon stands there, and the readout says so with
-- the one move that is left.
local function rearm(ballId)
S.ballId = ballId
CatchThrow.lastBall = ballId
S.ballInst = Pokeball.new(ballId)
S.ballInst.pos = handPos()
S.empty = ballId == nil
if ballId then
CatchThrow.lastBall = ballId
S.ballInst = Pokeball.new(ballId)
S.ballInst.pos = handPos()
else
S.ballInst.visible = false
S.canSwitch = false
end
S.battle.phase = CatchThrow.PHASE -- park the engine's menu again
S.phase = "aim"
S.clock = 0
@@ -1135,8 +1227,12 @@ local function winToGB(x, y)
return (px - shot.lx) / shot.scale, (py - shot.ly) / shot.scale
end
-- An EMPTY hand is not aimable: with no ball there is nothing to drag, so
-- the pointer seams stand down entirely and the mouse/finger goes back to
-- whatever owned it -- rather than the player wrestling an invisible ball
-- around a screen that can never throw it.
local function aiming()
return S ~= nil and S.phase == "aim"
return S ~= nil and S.phase == "aim" and not S.empty
end
local function pointer(kind, x, y, id)
@@ -1154,12 +1250,88 @@ local function pointer(kind, x, y, id)
end
end
-- ------- the BUTTONS, read on the LOGIC STEP rather than the frame
--
-- Everything else in this file is presentational and rides the render
-- frame (Pipelines.update). Button EDGES cannot: Input:step rebuilds
-- `pressed` from scratch once per FIXED step, and Game:update runs the
-- fixed steps for the frame FIRST and Pipelines.update after -- so a
-- frame that runs two steps has already thrown the first step's edges
-- away by the time anything on the render clock looks at them.
--
-- Which is not a rare race. It is every frame below 60fps: the key is
-- queued between frames, the frame's FIRST step promotes it, the SECOND
-- wipes it, and the poll never sees it at all. A 3D battle is exactly
-- where the frame rate goes under, so B-to-run, A-to-throw and the L/R
-- ball switch were all reliably dead on the machines that most needed
-- them and fine on a 144Hz one (where most frames run no step at all and
-- the edge lingers). Read from the pressQueue on the engine's own
-- input.step seam instead: it fires once per logic step, never skipped,
-- with this step's presses still in the queue.
--
-- Taken rather than peeked, so a press the capture consumed does not also
-- page the message it just queued.
local function take(g, btn)
local q = g and g.input and g.input.pressQueue
if not q then return false end
local hit = false
for i = #q, 1, -1 do
if q[i] == btn then
table.remove(q, i)
hit = true
end
end
return hit
end
function CatchThrow.buttons(g)
if not (S and S.phase == "aim") then return end
-- This runs on EVERY logic step for the whole session, so it has to be
-- certain the fight it is aiming into is still the thing on screen. A
-- session that outlived its battle -- a script tearing the fight down, a
-- forced finish, an error between the throw and the sweep -- would
-- otherwise sit in the overworld silently eating A, B and L/R out of the
-- queue every step, which reads as "the buttons stopped working" and
-- points nowhere near here.
local b = S.battle
if not b or b.result then return end
if not (g and g.stack and g.stack:top() == b) then return end
-- the same beat of deafness the drag has: the A that picked the ball out
-- of the bag menu is still this step's edge, and must not become a throw
if S.clock < 0.25 then return end
if S.empty then
-- A as well as B: nothing here can be confirmed, so the confirm button
-- should get the player out rather than do nothing at all
local b, a = take(g, "b"), take(g, "a")
if b or a then CatchThrow.cancel(true) end
return
end
if S.declinable and take(g, "b") then
CatchThrow.cancel(true)
return
end
if take(g, "a") then tapThrow() end
if take(g, "left") then switchBall(-1) end
if take(g, "right") then switchBall(1) end
end
function CatchThrow.installInput()
if installed then return end
installed = true
local Game = require("src.core.Game")
-- the logic-step seam for the buttons (see above). next_ first, so a
-- tool mod injecting presses on this hook is read like a controller.
local mod = V.mod
if mod and mod.hooks then
mod.hooks:wrap("input.step", function(next_, g, dt)
local r = next_(g, dt)
pcall(CatchThrow.buttons, g)
return r
end)
end
-- ------- mouse
--
-- Installed after CamControl's wraps (see main.lua's install order), so
@@ -1340,6 +1512,18 @@ function CatchThrow.update(dt)
end
if S.phase == "aim" then
-- ------- the empty hand
--
-- No ball, so no drag, no wind-up, no throw and no ring: the seat and
-- the Pokemon are all that is left, and the only input that means
-- anything is leaving. A runs as well as B on purpose -- there is no
-- second choice for it to be confused with, and a player mashing the
-- confirm button at a screen that cannot confirm anything should get
-- out rather than get stuck.
if S.empty then
if BattleScene.capture then BattleScene.capture.hideTextBox = true end
return -- A/B are read on the logic step, below
end
-- the foe's body, if no measurement landed at begin (the texture pass
-- and the session race on the entry frame; a model needs its pack
-- warm): retried while aiming, briefly -- the default torso covers a
@@ -1407,36 +1591,17 @@ function CatchThrow.update(dt)
if BattleScene.capture then BattleScene.capture.hideTextBox = true end
stickInput(dt)
-- a beat of deafness on entry: the A that picked the ball in the bag
-- menu is still this frame's edge, and it must not become the throw
if S.clock < 0.25 then return end
local input = game().input
if input and input.wasPressed then
if S.declinable and input:wasPressed("b") then
CatchThrow.cancel(true)
return
end
if input:wasPressed("a") then tapThrow() end
if input:wasPressed("left") then switchBall(-1) end
if input:wasPressed("right") then switchBall(1) end
end
-- B, A and the L/R ball switch are NOT read here: button edges do not
-- survive the render clock (CatchThrow.buttons, on the logic step)
return
end
-- FULL, between throws: the miss text is playing, the seat is held, and
-- the moment the engine offers the menu the next ball is in the hand
-- instead. Out of balls, the hold releases and the classic menu takes
-- over -- there is nothing left to be in throw mode WITH.
-- instead -- or, when that was the last ball, the empty hand is (rearm
-- takes nil for exactly this). Either way the capture screen stays up.
if S.phase == "await" then
if S.battle.phase == "menu" then
local ball = CatchThrow.pickBall()
if ball then
rearm(ball)
else
endSession()
end
end
if S.battle.phase == "menu" then rearm(CatchThrow.pickBall()) end
return
end
+40 -4
View File
@@ -52,6 +52,7 @@ local V = ...
local Assets = require("src.render.Assets")
local Structures = V.require("Structures")
local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Voxel3D = V.require("Voxel3D")
local Budget = V.require("BuildBudget")
@@ -102,6 +103,12 @@ local SIDES = {
{ 0, -1, 6 }, -- -Z north
}
-- How far sideways a face reaches for ordinary wall when the column it
-- stands over draws a doorway or a sign (see wallTile), nearest ring
-- first. Left before right at each distance is arbitrary and only decides
-- symmetric cases.
local SPAN = { { -1, 1 }, { -2, 2 } }
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
@@ -248,6 +255,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
return s and s.h or 0
end
-- The tiles that belong on a DRAWN FACADE and nowhere else -- doorways,
-- shop signs, the gyms' lettering (data/voxel_heights.lua `frontOnly`).
-- A volume folds its column's drawing up all four sides, so without this
-- a house's back and flanks each carry their own copy of its front door.
-- The face keeps the same map row and reaches sideways for an ordinary
-- column instead, which is the neighbouring course of the same wall.
-- The search stays inside the structure -- a neighbour column with no run
-- of its own is the ground beside the building, and a doorway that
-- borrowed grass would be a hole. Two columns is as far as it needs to
-- reach: every doorway in the game is two tiles wide.
local frontOnly = Buildings.frontOnly(tileset.id)
local function wallTile(tx, ty)
local tile = map:tileAt(tx, ty)
if not (frontOnly and frontOnly[tile]) then return tile end
for d = 1, 2 do
for _, nx in ipairs(SPAN[d]) do
nx = tx + nx
if S.runs[keyOf(nx, ty)] then
local n = map:tileAt(nx, ty)
if not frontOnly[n] then return n end
end
end
end
return tile
end
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
local function uvRect(tile, vTop, vBot)
local ax = (tile % perRow) * 8
@@ -605,13 +638,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- drawing itself (full brightness); the other sides wear
-- the same rows darkened, so a building's flank matches
-- its face instead of smearing one tile
local sy
if d == 6 then
src = map:tileAt(tx, math.min(run.front,
run.north + band))
sy = math.min(run.front, run.north + band)
else
src = map:tileAt(tx, math.max(run.north,
run.front - band))
sy = math.max(run.north, run.front - band)
end
-- the south face IS the drawing and keeps every tile of
-- it; the back and the flanks are the same wall seen from
-- somewhere the door and the sign are not
src = (d == 5) and map:tileAt(tx, sy) or wallTile(tx, sy)
if d == 5 then shade = 1 end
elseif s.art == "upright" then
-- profile-authored upright (a pinned wall or furniture
+41 -7
View File
@@ -104,10 +104,25 @@ local function vrOn()
return ok and vr and vr.enabled and vr.enabled() or false
end
-- ------- the battles that are cutscenes wearing a battle's clothes
--
-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK
-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one
-- flag covers both) -- are scripted from the first frame: the cursor moves
-- itself, the bag opens itself, the ball is thrown by someone who is not
-- the player, and the throw always catches a Pokemon nobody keeps. There
-- is no decision in them to hand a minigame, and the story beat is the
-- point, so LET'S GO stays out of them entirely at whatever rung: no
-- capture screen, no FULL treatment, no experience.
function LetsGo.scripted(battle)
return battle and (battle.demo or battle.oakDemo) and true or false
end
function LetsGo.wantsMinigame(battle)
if not LetsGo.mode() then return false end
if not battle or battle.kind ~= "wild" then return false end
if battle.demo or battle.ghost or battle.noCatch then return false end
if LetsGo.scripted(battle) then return false end
if battle.ghost or battle.noCatch then return false end
if not Voxel3D.available() or vrOn() then return false end
-- the staged shot must actually be standing: this is "there is a 3D
-- battle on screen right now", which the throw is aimed into
@@ -123,8 +138,8 @@ end
-- from the wipe to the last message.
function LetsGo.fullWild(battle)
return LetsGo.mode() == "full" and battle and battle.kind == "wild"
and not (battle.safari or battle.demo or battle.ghost
or battle.noCatch)
and not LetsGo.scripted(battle)
and not (battle.safari or battle.ghost or battle.noCatch)
and true or false
end
@@ -237,9 +252,18 @@ local function autoEnter()
if battle.dramaticShapeDeclined then return end
if not LetsGo.wantsMinigame(battle) then return end
local ball = CatchThrow.pickBall()
if not ball then return end
local full = LetsGo.fullWild(battle)
-- An empty bag does NOT fall back to the classic menu under FULL. A
-- Let's Go wild has no player Pokemon in it and a foe that never takes a
-- turn, so the menu it would fall back to offers a FIGHT that cannot
-- happen -- the encounter has to keep its own screen and its own exit.
-- The capture screen opens empty-handed instead: the foe stands there,
-- the readout says there is nothing to throw, and RUN is the way out.
-- At CATCH ONLY there is no auto-entry to speak of and the bag is the
-- only route in, so no balls simply means no throw, as it always did.
if not (ball or full) then return end
CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
fullWild = LetsGo.fullWild(battle) })
fullWild = full })
end
-- ------- per frame, from the voxel pipeline's update hook
@@ -299,9 +323,17 @@ function LetsGo.install()
end
-- a catch pays experience under FULL, exactly as a knockout would
-- Never for a scripted demo: the old man's catch is a cutscene, nobody
-- keeps the Pokemon, and the party it would pay may not exist yet
-- (Yellow's Pallet intro runs before the lab gift). The engine's own
-- flow does not reach either hook for a demo today -- oldManThrow ends
-- the battle without storeCaughtMon or awardExp -- so this guards the
-- INVARIANT rather than a live bug: a demo pays nothing, whatever route
-- some later engine takes to get there.
mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
if LetsGo.mode() == "full" and ctx and ctx.battle
and ctx.battle.kind == "wild" then
and ctx.battle.kind == "wild"
and not LetsGo.scripted(ctx.battle) then
return true
end
return next_(ctx)
@@ -358,6 +390,7 @@ function LetsGo.install()
end
mod.hooks:wrap("battle.exp_award", function(next_, ctx)
if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end
local cc = expCtx
if cc and ctx and ctx.battle == cc.battle then
expCtx = nil
@@ -398,7 +431,8 @@ function LetsGo.install()
local b = payload and payload.battle
if not (b and LetsGo.fullWild(b)) then return end
if not (Voxel3D.available() and not vrOn()) then return end
if not CatchThrow.pickBall() then return end
-- deliberately NOT gated on owning a ball: an empty bag still gets the
-- Let's Go encounter (see autoEnter), so the send-out still has to go
local q = b.queue
local n = q and #q or 0
if n >= 6 and type(q[n]) == "table" and q[n].fn
+10 -6
View File
@@ -168,6 +168,15 @@ function ModSetting:sync(value)
self.index = indexOf(self, value)
end
-- The label of the rung actually in force, which is not the stored one when
-- that rung has been gated away (see get). Its own entry point because a
-- caller can want the label without wanting a row: SettingsMenu puts one
-- setting's rung on the second line of the CATEGORY that contains it.
function ModSetting:valueLabel()
local i = self:read()
return self.labels[self:allows(i) and i or 1]
end
-- The descriptor src/ui/OptionRows.lua renders, in the shape the
-- ui.options.rows hook appends.
function ModSetting:row()
@@ -175,12 +184,7 @@ function ModSetting:row()
return {
id = "DRAMATIC_SHAPE:" .. self.key,
label = self.label,
-- the label of the rung actually in force, which is not the stored one
-- when that rung has been gated away (see get)
value = function()
local i = self_:read()
return self_.labels[self_:allows(i) and i or 1]
end,
value = function() return self_:valueLabel() end,
step = function(game, dir)
self_:cycle(game, dir)
return true
+47 -1
View File
@@ -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,43 @@ function OverworldBattle.sideTexture(battle, side)
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
texturing = side
-- A SHINY on this side, tinted here rather than in ShinyUI's flat-path
-- wrap. This is the one place a pic is rendered for ONE side at a time,
-- so it is the only place the two sides can be tinted differently -- a
-- shiny facing an ordinary mon gets its own colour and leaves the other
-- alone, which the engine's both-sides-at-once pic layer cannot do.
local shinyTint = nil
do
-- NOT when this side is showing a PERSON. Both sides can be holding a
-- trainer pic rather than a Pokemon -- the foe's portrait before the
-- send-out, and the player's own back until "Go!" -- and a shiny is a
-- fact about a Pokemon, not about its owner. Tinting through it turned
-- the player's trainer sprite a different colour for the whole intro,
-- which is what a shiny Pokemon in the party looks like if you do not
-- ask this question. The two tests are the same ones sideTexture already
-- uses to label the finished texture, asked here instead of after.
local person = (side == "enemy"
and battle.showEnemyTrainer and battle.trainerPic)
or (side == "player"
and battle.showPlayerBack and battle.playerBackPic)
if not person then
local battler = (side == "player") and battle.player or battle.enemy
local g2 = game()
shinyTint = battler and V.require("ShinyUI")
.tintFor(battler.mon, g2 and g2.data) or nil
end
end
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
+178
View File
@@ -0,0 +1,178 @@
-- SELECT on a row explains what it does.
--
-- ------- why this exists at all
--
-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
-- into the schema handed to the mod manager (ModSetting:schema takes it), it
-- has been written and kept up to date beside every row in main.lua's
-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
-- menu, whose row is a label and a value and has no room for a third thing;
-- not the mod manager's own page, which renders the same two lines. It was
-- authored, structured, accurate prose sitting in a field with no reader.
--
-- So it gets one. A row on this mod's menus says what it IS on one line and
-- what it is SET TO on the next, and SELECT says what that means -- which is
-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
-- characters however the label is worded.
--
-- SELECT rather than a button that already does something: A steps a setting,
-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
-- SELECT hotkey is installed on OverworldController:handleInput, which only
-- runs while the overworld is the top state, so a menu can have the button
-- without taking anything from the map.
--
-- ------- the shape of it
--
-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
-- ROM's own glyphs and a mod-supplied font theme retextures this along with
-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
-- with the menu still visible above it, which is where this game has put
-- every line of text anybody has ever read in it.
--
-- Sized to what it holds rather than to the screen. Each description is one
-- sentence, so most of these are five or six tiles tall and the row being
-- asked about is still on screen over the top of the box. A sentence long
-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
-- time on the d-pad -- which is a fallback, not the design: the answer to a
-- description that needs scrolling is a shorter description.
-- the mod namespace (see main.lua)
local V = ...
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local PaletteFX = require("src.render.PaletteFX")
local SettingsHelp = {}
SettingsHelp.__index = SettingsHelp
-- NOT opaque: the menu stays drawn underneath, so the row being asked about
-- is still on screen above the box. That is most of why the box is only as
-- tall as it needs to be.
SettingsHelp.isOpaque = false
-- The box spans the screen's twenty tiles and its border owns the outer ring,
-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
-- is kept clear for the more-arrow, which would otherwise land on top of the
-- last character of any line that filled the width.
local COLS = 17
local PEN_X = 8
local SCREEN_ROWS = 18
-- title, plus the body, plus the two border rows
local CHROME_ROWS = 3
-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
-- characters, which is a long sentence and a box two thirds up the screen.
local MAX_LINES = 8
-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
-- show, and this one is the whole point of the screen.
local function wrapped(str, cols)
cols = cols or COLS
local lines, line = {}, nil
for word in tostring(str or ""):gmatch("%S+") do
local try = line and (line .. " " .. word) or word
if #try <= cols then
line = try
else
if line then lines[#lines + 1] = line end
-- a word longer than the line is broken across lines rather than cut;
-- nothing in the help text is that long today, but losing the end of a
-- sentence silently is not a failure mode worth leaving open
while #word > cols do
lines[#lines + 1] = word:sub(1, cols)
word = word:sub(cols + 1)
end
line = word
end
end
if line then lines[#lines + 1] = line end
return lines
end
SettingsHelp.wrapped = wrapped
function SettingsHelp.new(game, title, body)
return setmetatable({
game = game,
title = tostring(title or ""):gsub("%.%.$", ""),
lines = wrapped(body),
top = 0,
}, SettingsHelp)
end
-- How many body lines this box shows: all of them, unless there are more than
-- a box is allowed to be tall.
function SettingsHelp:bodyRows()
return math.min(#self.lines, MAX_LINES)
end
function SettingsHelp:maxTop()
return math.max(0, #self.lines - self:bodyRows())
end
-- Every button that could mean "done" closes it, including SELECT itself --
-- the press that opened the box is the one a player is most likely to reach
-- for to get rid of it. A is in there too: it steps a setting everywhere else
-- on these menus, and stepping one you cannot see would be worse than an
-- extra way out.
local DISMISS = { "a", "b", "start", "select" }
function SettingsHelp:update()
local input = self.game and self.game.input
if not input then return end
local maxTop = self:maxTop()
-- the d-pad only does anything when there is something below the fold; a
-- box showing its whole sentence has nowhere to go and says so by not
-- moving
if input:wasPressed("down") then
self.top = math.min(maxTop, self.top + 1)
return
elseif input:wasPressed("up") then
self.top = math.max(0, self.top - 1)
return
end
for _, btn in ipairs(DISMISS) do
if input:wasPressed(btn) then
local stack = self.game.stack
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if stack and stack:top() == self then stack:pop() end
return
end
end
end
function SettingsHelp:draw()
local body = self:bodyRows()
local th = body + CHROME_ROWS
local ty = SCREEN_ROWS - th -- anchored to the bottom of the screen
Font.drawBox(0, ty, 20, th)
love.graphics.setColor(0, 0, 0, 1)
-- the row's own name, so the box says what it is about even where it covers
-- the row that was asked
Font.draw(self.title, PEN_X, (ty + 1) * 8)
for i = 1, body do
local line = self.lines[self.top + i]
if not line then break end
Font.draw(line, PEN_X, (ty + 1 + i) * 8)
end
-- the same marker the options list uses for "there is more below this", so
-- it means here what it means there
if self.top < self:maxTop() then
Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Game:draw stops at the first state that HAS this method, so without one the
-- box would inherit whatever is underneath -- which is a menu of ours, whose
-- answer happens to be right. Stated anyway: the reason that answer is right
-- is not a property of this screen, and a future menu that paints something
-- of its own would silently repaint this box with it.
function SettingsHelp:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
return SettingsHelp
+464
View File
@@ -0,0 +1,464 @@
-- This mod's settings, in categories, on menus of their own.
--
-- ------- why the flat list had to end
--
-- Every setting used to be spliced straight into the engine's OPTIONS list,
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
-- was four screens of scrolling inside a list that already carried twenty
-- engine rows -- and a player looking for SHADOWS had to know it was in there
-- somewhere, past the wireframe and the horizon bend.
--
-- The engine has no grouping to borrow: a row descriptor is
-- { id, label, value, step, activate } and nothing else. No headers, no
-- sections, no pages. What it DOES have is `activate`, and a state stack that
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
--
-- ------- how the split was chosen
--
-- Not invented here: the mod already sorted its own settings, in the `full`
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
-- take away, and the reasoning written next to each one is always the same
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
-- the diorama FULL is a preset for.
--
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
-- case to disappear under FULL: every child filters itself out and the
-- category goes with them (see rows). PERFORMANCE is the three rows marked
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
-- are the two features that are not about the look at all.
--
-- ------- what did NOT change
--
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
-- row id, so options.lua is byte-identical for a player who upgrades and
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
-- identity a setting has. The hotkeys are untouched too, which is what makes
-- the nesting affordable: a buried row is still one keypress away.
-- the mod namespace (see main.lua)
local V = ...
local OptionRows = require("src.ui.OptionRows")
local PaletteFX = require("src.render.PaletteFX")
local SettingsMenu = {}
SettingsMenu.__index = SettingsMenu
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
-- covered, so there is no reason to pay for drawing it.
SettingsMenu.isOpaque = true
SettingsMenu.ROOT = "root"
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
-- Row ids live in a namespace of their own -- "menu." rather than a setting
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
-- settings rows have carried since the beginning.
function SettingsMenu.id(catId)
return "DRAMATIC_SHAPE:menu." .. catId
end
-- ------- the categories, in menu order
--
-- `summary` is the second line of the category's own row, the way MODS reads
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
-- current rung, which is the thing a player actually wants to know without
-- opening it. Where no single row speaks for the rest, it counts them, which
-- is honest rather than arbitrary.
SettingsMenu.CATEGORIES = {
{ id = "world", label = "3D WORLD..",
help = "The diorama itself: how far the world bends, how much of it is "
.. "drawn, what the water does and what hour it is outdoors." },
{ id = "battles", label = "BATTLES..",
summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
help = "What a fight is drawn over, how it is framed, and how a ball is "
.. "thrown." },
{ id = "perf", label = "PERFORMANCE..",
help = "What the look costs -- the three most expensive things in the "
.. "frame after the geometry itself." },
{ id = "vr", label = "VR..",
summary = function() return V.require("VR").setting:valueLabel() end,
help = "PCVR through OpenXR, and the one comfort setting that belongs to "
.. "the headset alone." },
}
-- ------- help for the rows that are not settings
--
-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
-- next to the row it explains. What is left is the two pipeline rows -- whose
-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
-- and the ROM import, which is an action rather than a setting and has no
-- SETTINGS entry to live in.
local ROW_HELP = {
["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
.. "by a 3D camera, with the numbered rungs its angle in degrees.",
["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
.. "look, sharp across the middle and softening above and below it.",
["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
.. "cartridge that 3D-BTL's STADIUM rungs need.",
}
-- ------- what the menus are built from
--
-- SETTINGS lives in main.lua, next to the help text that goes with each row
-- and the comments explaining every `when` and `full`. It is handed here
-- rather than moved, so this file stays about PRESENTATION and that one stays
-- the single place the mod's settings are declared.
local settings = {}
local pipelineRows = {}
function SettingsMenu.define(list)
settings = list or {}
end
-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
-- the category's out of CATEGORIES, or one of the three above for the rows
-- that have nowhere else to keep it.
--
-- Looked up BY ID rather than hung on the row as a field, because two of
-- these rows are the engine's own tables reused verbatim -- and annotating
-- somebody else's table is how a mod ends up owning a field it never meant
-- to. nil for a row with nothing to say, which SELECT reads as "no box".
function SettingsMenu.helpFor(id)
if ROW_HELP[id] then return ROW_HELP[id] end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
if SettingsMenu.id(cat.id) == id then return cat.help end
end
for _, entry in ipairs(settings) do
if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
end
return nil
end
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
-- .rows), captured by the options hook on its way past and shown here instead
-- of at the top level. Reused verbatim, tables and all: they persist in
-- save.options.pipelines through their own step functions, and rebuilding
-- them here would be a second implementation of a thing the engine already
-- got right.
function SettingsMenu.setPipelineRows(rows)
pipelineRows = rows or {}
end
-- ------- a step here has the same consequences as a step anywhere
--
-- Two of these settings PIN something else when they change: 3D-BTL holds
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
-- pinning on its way past.
--
-- Nothing reruns that hook from in here, so the pin is asked for directly.
-- main.lua supplies it, because WHICH values follow which is a question about
-- the mod's settings and not about the menu they are on.
local onChanged = nil
function SettingsMenu.setOnChanged(fn)
onChanged = fn
end
local function isFull()
local Pipelines = require("src.render.Pipelines")
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
end
-- The one rule that decides whether a setting is on a menu, lifted unchanged
-- from the options hook it used to live in.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. And a row whose own switch is off the table this frame (BACK
-- SPRITES, which needs a staged fight to be about) is left off with it. The
-- mod manager's page carries every one of them either way.
local function offered(entry, full)
return (entry.full or not full) and (not entry.when or entry.when())
end
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
-- no side effects -- so a caller that only wants to know what is on a menu
-- (a test, or the root menu asking whether a category has anything in it)
-- does not have to push a screen to find out.
function SettingsMenu.rows(catId, game)
local full = isFull()
local out = {}
if catId == SettingsMenu.ROOT then
for _, row in ipairs(pipelineRows) do
-- FULL owns the blur exactly as it owns the wireframe and the horizon
-- bend, so T-SHIFT comes off with them
if not (full and row.id == "pipeline:tiltshift") then
out[#out + 1] = row
end
end
-- ------- settings that belong to no category
--
-- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
-- menu next to the pipeline rows. For a setting that is about the GAME
-- rather than about one of the four things the categories are for --
-- SHINY ODDS is the first -- burying it under a heading it does not
-- belong to is worse than the flat list this menu was built to end.
--
-- Above the categories, because these are rows you CHANGE and those are
-- rows you OPEN: everything with a value on it stays together at the top
-- of the screen, and the "..." rows read as the way further in.
for _, entry in ipairs(settings) do
if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
out[#out + 1] = entry[1]:row()
end
end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
local kids = SettingsMenu.rows(cat.id, game)
-- An EMPTY category is not offered. This is the whole of what makes
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
-- special case, just nothing left inside to open.
if kids[1] then
out[#out + 1] = {
id = SettingsMenu.id(cat.id),
label = cat.label,
value = cat.summary
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
activate = function(g)
g.stack:push(SettingsMenu.new(g, cat.id))
end,
}
end
end
-- ------- and the ROM import, last, on the top-level menu
--
-- An ACTION and not a setting: there is no rung to store, nothing for the
-- mod manager's page to persist and nothing to restore on the next boot,
-- so it is appended rather than living in SETTINGS.
--
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
-- and wait while it builds -- and a player who has been told to import a
-- ROM should find the row where the mod begins, not two levels down a
-- category they have no reason to open until it has worked. Last, because
-- the categories are what the menu is FOR.
local ok, importRow = pcall(function()
return V.require("StadiumRomPick").row()
end)
if ok and importRow then out[#out + 1] = importRow end
return out
end
for _, entry in ipairs(settings) do
if entry.cat == catId and offered(entry, full) then
out[#out + 1] = entry[1]:row()
end
end
return out
end
-- ------- red ink for the mod's row on the OPTIONS menu
--
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
-- nobody spends an afternoon on it. Twice over:
--
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
-- MULTIPLICATIVELY. black x red is black.
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
--
-- What actually happens on this screen is that setColor picks a SHADE and the
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
-- The engine does the same thing for the party menu's HP bars
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
SettingsMenu.INK = { 255, 0, 0 }
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
-- the row is the same white as the row above it in all three.
function SettingsMenu.redPalette(data)
local base = PaletteFX.pal(data, "MEWMON")
if not base then return nil end
local out = { base[1], base[2], base[3], base[4] }
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
-- it the ink is the first slot and the paper the last. Put the red where it
-- will land on the INK either way: without this the row draws as a solid
-- red block with white letters cut out of it.
--
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
-- outright and substitute their own, so the row simply draws monochrome --
-- which is correct: the player asked for a screen with no colors in it.
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
return out
end
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
-- black glyphs that would turn red along with the text if the band spanned
-- the whole row.
function SettingsMenu.rowZone(data, slot)
local pal = SettingsMenu.redPalette(data)
if not pal then return nil end
local top = (slot - 1) * 4 + 1
return PaletteFX.zone(pal, 2, top, 18, top + 1)
end
-- ------- the screen
--
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
-- level or the battle rows change -- which would replace a submenu's rows
-- with the whole top-level OPTIONS list under the player's cursor. A state of
-- our own cannot be caught by it.
--
-- It still renders through OptionRows, so it is the same four boxes, the same
-- cursor and the same bottom line as every other menu in the game.
function SettingsMenu.new(game, catId)
local self = setmetatable({
game = game,
cat = catId or SettingsMenu.ROOT,
index = 1,
scroll = 0,
}, SettingsMenu)
self.rows = SettingsMenu.rows(self.cat, game)
self.sig = SettingsMenu.signature(self.rows)
return self
end
function SettingsMenu.signature(rows)
local ids = {}
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
return table.concat(ids, "\1")
end
-- The bottom line is the only place on this screen to say anything that is
-- not a row: OptionRows' four boxes fill everything above it and there is no
-- header slot. It spends that line on the two buttons that are not obvious.
--
-- It used to carry the category's NAME instead, for orientation. The hint
-- won: a binding nobody knows about is worth nothing, and where the player is
-- was just answered by the row they pressed A on. Sixteen characters of the
-- eighteen the line has, which is also why the name could not stay -- "BACK:
-- PERFORMANCE" is seventeen on its own.
SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
function SettingsMenu:backLabel()
return SettingsMenu.BACK_LABEL
end
-- A category's contents can change while the player is looking at them: 3D-BTL
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
-- actually differs, so the common case -- every other rung of every other row
-- -- costs one string compare.
function SettingsMenu:refresh()
local rows = SettingsMenu.rows(self.cat, self.game)
local sig = SettingsMenu.signature(rows)
if sig == self.sig then return end
-- Follow the row the cursor was ON rather than the slot it was in: a row
-- can appear ABOVE the one just used, which would otherwise slide the
-- cursor onto its neighbour. The bottom line follows itself.
local wasBack = self.index > #self.rows
local wasOn = self.rows[self.index] and self.rows[self.index].id
self.rows, self.sig = rows, sig
self.index, self.scroll = 1, 0
if wasBack then
self.index = #rows + 1
else
for i, row in ipairs(rows) do
if wasOn and row.id == wasOn then self.index = i break end
end
end
end
local function pop(self)
local stack = self.game and self.game.stack
if self.game and self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if stack and stack:top() == self then stack:pop() end
end
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
-- alone, and the bottom line is a synthetic index past the end of the list
-- rather than a row, so nothing a category contains can orphan the way out.
function SettingsMenu:update()
local input = self.game and self.game.input
if not input then return end
local rows = self.rows
local back = #rows + 1
local changed = false
if input:wasPressed("up") then
self.index = self.index - 1
if self.index < 1 then self.index = back end
elseif input:wasPressed("down") then
self.index = self.index + 1
if self.index > back then self.index = 1 end
elseif input:wasPressed("left") or input:wasPressed("right")
or input:wasPressed("a") then
local dir = input:wasPressed("left") and -1 or 1
local row = rows[self.index]
if row and row.activate then
if input:wasPressed("a") then row.activate(self.game) end
elseif row and row.step then
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then
pop(self)
return
end
elseif input:wasPressed("select") then
-- SELECT explains the row the cursor is on. Every row on these menus has
-- something to say -- the settings have carried a paragraph each since
-- they were written, and nothing has ever drawn it (see SettingsHelp) --
-- but a row that does not is simply left alone rather than opening an
-- empty box.
local row = rows[self.index]
local help = row and SettingsMenu.helpFor(row.id)
if help and self.game.stack then
self.game.stack:push(
V.require("SettingsHelp").new(self.game, row.label, help))
end
return
elseif input:wasPressed("b") or input:wasPressed("start") then
-- B and START both, like every other menu -- and one level only: this
-- pops US, leaving the OPTIONS menu underneath exactly as the player
-- left it, with its own onCancel still to fire when they leave THAT.
pop(self)
return
end
if changed then
-- before the rebuild, not after: pinning can itself change which rows are
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
-- it), and refresh has to see the settled answer
if onChanged then pcall(onChanged, self.game) end
if self.game.writeOptions then
pcall(self.game.writeOptions, self.game)
end
end
self:refresh()
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
#self.rows + 1)
end
function SettingsMenu:draw()
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
self:backLabel(), #self.rows + 1)
end
-- REQUIRED, even though nothing here is red.
--
-- Game:draw walks the stack from the top and stops at the first state that
-- HAS this method, not the first that answers something. Without one of our
-- own the walk would fall through to the OPTIONS menu underneath -- whose
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
-- zone is addressed by SLOT, so it would land on whatever this menu happens
-- to be showing in the same box.
--
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
function SettingsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
return SettingsMenu
+23 -3
View File
@@ -217,10 +217,27 @@ local function getBlank()
return blank or nil
end
-- Whether the player asked for shadows at all (the SHADOWS row, see
-- lib/Shadows). Asked through a pcall because this module is loaded by
-- probes and by the suite with no mod namespace around it, where the answer
-- is simply yes.
--
-- ONE gate for both halves of the module -- can the pass run, and is there
-- a map to read -- because they must never disagree: available() alone
-- would leave the LAST map standing (`ready` is still true), and every
-- surface would go on wearing shadows frozen in the pose the row was
-- switched off in.
function ShadowMap.wanted()
local ok, on = pcall(function() return V.require("Shadows").enabled() end)
return (not ok) or on
end
-- Whether the sun pass can run at all. False headless, without shaders, or
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
-- shadows, which need nothing but a quad.
-- shadows, which need nothing but a quad -- and false with the row off,
-- where nothing stands in (see lib/Shadows).
function ShadowMap.available()
if not ShadowMap.wanted() then return false end
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
return false
end
@@ -241,9 +258,12 @@ function ShadowMap.texture()
return getBlank()
end
-- True while the map holds a frame the main pass can read.
-- True while the map holds a frame the main pass can read. The row's OFF
-- lands here as well as on available(): a map drawn a frame ago is still in
-- the canvas, and every reader (the scene shader's sunDark, the water's,
-- the forest's beams) hangs off this one answer.
function ShadowMap.active()
return ready and canvas ~= nil and canvas ~= false
return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted()
end
-- The direction the light TRAVELS, normalized. The shear is the shadow a
+60
View File
@@ -0,0 +1,60 @@
-- Voxel world mode: whether the sun casts at all.
--
-- lib/ShadowMap renders the whole scene a second time from the light every
-- frame the view or a pose changes, at up to 2048 squared, and every
-- surface in the main pass then takes four taps at it. That is the single
-- most expensive thing this mode does after the geometry itself -- and on a
-- phone, or an old laptop, it is the difference between the diorama running
-- and the diorama stuttering. So it gets a row.
--
-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
-- a stand-in for a machine that wanted shadows and could not have them.
-- A player who has just switched them off wants no shadow under anybody,
-- which is what this row gives -- see ShadowMap.wanted, the one gate both
-- halves hang off.
--
-- This file owns the toggle rather than the drawing: the value, where it
-- persists, and the row the player finds it on -- exactly as VoxelGrid does
-- for the wireframe.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Shadows = {}
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
-- OPTIONS and the mod manager's own settings page for this mod
Shadows.KEY = "shadows"
Shadows.LABEL = "SHADOWS"
-- ON is values[1] and so the default: cast shadows are what the mode is
-- for as much as the geometry is -- a world where a building throws
-- nothing reads as flat however many voxels it is made of. The row is for
-- the machine that cannot carry them, not a look anybody is choosing.
Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
{ true, false }, { "ON", "OFF" })
function Shadows.enabled()
return Shadows.setting:get() and true or false
end
function Shadows.set(enabled, game)
return Shadows.setting:setIndex(enabled and 1 or 2, game)
end
function Shadows.toggle(game)
return Shadows.setting:cycle(game)
end
function Shadows.sync(value)
Shadows.setting:sync(value and true or false)
end
function Shadows.row()
return Shadows.setting:row()
end
return Shadows
+289
View File
@@ -0,0 +1,289 @@
-- 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 ModSetting = V.require("ModSetting")
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
-- ------- the row the player cycles
--
-- A ladder that HALVES, so every step is exactly "twice as often as the one
-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
-- rate is what the number says, not an approximation of it, because the
-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
-- 1:2 really is every other encounter.
--
-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
-- and the fallback for an unreadable or unrecognised stored value, so the
-- canonical rate is what a player who never opens the menu gets and what a
-- corrupted options.lua comes back to.
--
-- No rung RARER than 8192. The mod's promise is that its default is not a
-- change to the game; making the game harder than it ships is a different
-- promise and nobody asked for it.
local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
local ODDS_LABELS = {}
for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
-- ------- the setting is PULLED, not pushed
--
-- decide() asks this every roll rather than the menu telling us when it
-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
-- settings page -- and only the first has a change hook to hang on; the
-- manager writes through mod.options and calls ModSetting:sync, which
-- notifies nothing. Pulling is the only way both are seen, and the cost is
-- a table read on an event that happens once per encounter.
--
-- ODDS_DENOM stays the live value and is written through on every ask, so
-- anything already reading that field keeps reading the truth.
local pinned = false
function Shiny.odds()
if not pinned then
local ok, value = pcall(Shiny.setting.get, Shiny.setting)
local n = ok and tonumber(value)
if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
end
return Shiny.ODDS_DENOM
end
-- Set the denominator BY HAND, which also PINS it: a driver or a test that
-- has asked for 1:1 means it, and must not have the next roll quietly put
-- back to whatever the player left on the menu. Nothing in the game calls
-- this -- the row is how a player changes the rate.
--
-- 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)
pinned = true
return Shiny.ODDS_DENOM
end
-- Hand the row back control, for a test that pinned the odds and wants the
-- setting to mean something again afterwards.
function Shiny.unpinOdds()
pinned = false
return Shiny.odds()
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()) == 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
+97
View File
@@ -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
+324
View File
@@ -0,0 +1,324 @@
-- 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 = {}
local max, min = math.max, math.min
-- ------- shape and timing
-- ------- sized to the Pokemon, not to a constant
--
-- A Pokemon on the map is between 5 and 18 world pixels tall
-- (StadiumMon.MIN_HEIGHT/MAX_HEIGHT, REF_HEIGHT 14) and roughly its own
-- radius wide. Every earlier attempt here used flat numbers and every one of
-- them was wrong for most of the dex: first a ring 7 units across, which sat
-- INSIDE anything bigger than a Rattata and was depth-rejected; then, over-
-- correcting, a ring 24 across starting 20 units up -- taller than the
-- tallest Pokemon there is, so it hung in the sky above a Ponyta with
-- nothing under it.
--
-- One ring cannot fit a Diglett and a Gyarados. The burst is therefore a
-- FRACTION of the mon it belongs to: Stadium hands us each side's
-- worldHeight and worldRadius (StadiumMon has them, and worldRadius exists
-- precisely so "a caller can size something to its footprint"), and every
-- distance below is measured off those.
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
ShinyFx.STARS = 10 -- around the ring
ShinyFx.CHEST_FRAC = 0.50 -- up the body: the ring is centred on the
-- Pokemon, not perched above or below it
ShinyFx.RISE_FRAC = 0.16 -- of its height, drifted up over the burst
ShinyFx.SIZE_FRAC = 0.20 -- a star, as a fraction of the mon's height
-- THE RING IS AN ELLIPSE AROUND THE SILHOUETTE, with its two axes measured
-- separately. A single radius cannot do this: flattened enough to look like
-- a ring seen from the battle's low seat, its vertical reach ends up a third
-- of the body's height, so the top and bottom stars sit ON the Pokemon. The
-- horizontal axis clears its width, the vertical axis clears its height.
ShinyFx.RING_X_FRAC = 1.50 -- of the mon's RADIUS -- just outside its width
ShinyFx.RING_X_MIN = 0.34 -- ...but never narrower than this of its height,
-- for the thin ones (Onix, Ekans) whose radius
-- alone would put the ring inside them
ShinyFx.RING_Y_FRAC = 0.62 -- of its HEIGHT -- so the ring reaches its
-- shoulders and its feet, not just its middle
-- The burst OPENS from here rather than from nothing. Springing out of a
-- point means every star spends the first frames stacked at the centre --
-- which is the middle of the Pokemon, and reads exactly like the sparkles
-- being stuck inside it. Starting already clear of the body and expanding
-- the rest of the way keeps them outside for the whole life of the effect.
ShinyFx.RING_START = 0.72
-- What a side with no model gets: the flat-pic rung, where a pic stands
-- FULL_W (16) units wide in a card. Close enough to a median Pokemon that
-- the same fractions land sensibly.
ShinyFx.DEFAULT_HEIGHT = 14
ShinyFx.DEFAULT_RADIUS = 6
-- Additive drawing keeps the depth TEST (Voxel3D.blend sets lequal with
-- writes off), so a star level with the model is rejected by it however
-- bright it is. The extra pull puts the ring in front of the Pokemon it
-- belongs to, the same trick the move-animation card uses.
ShinyFx.PULL_BONUS = 6
-- one per side, nil when nothing is playing
local live = { player = nil, enemy = nil }
-- How big the Pokemon on each side actually is, pushed in by Stadium.update
-- every frame it has a model. Kept here rather than reached for, because
-- ShinyFx is drawn from BattleScene and asking Stadium from inside it would
-- close a require loop between the three.
local size = { player = nil, enemy = nil }
-- world pixels, from StadiumMon:worldHeight/worldRadius. Pass nil height to
-- say "no model on this side" -- the flat-pic rung, which falls back to the
-- defaults above.
function ShinyFx.setMetrics(side, height, radius)
if side ~= "player" and side ~= "enemy" then return end
if not (height and height > 0) then size[side] = nil return end
size[side] = { h = height, r = radius or 0 }
end
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.
-- ARMED, BUT NOT YET RUNNING. The clock does not start here, and that is the
-- whole point: the edge this is armed on -- a side's occupant changing --
-- happens while the screen is still mid-WIPE, a second or more before the
-- battle draws a single frame. A burst that started its three-quarter-second
-- life at that moment was always over before anybody could see it, which is
-- exactly what "the sparkle isn't appearing" looked like: armed, drawn,
-- counted, and finished behind the transition.
--
-- So `pending` holds it at frame zero until the scene actually draws this
-- side (see draw), and the life begins from there.
function ShinyFx.arm(side)
if side ~= "player" and side ~= "enemy" then return end
live[side] = { t = 0, pending = true }
if ShinyFx.debug then ShinyFx.debug.armed = (ShinyFx.debug.armed or 0) + 1 end
end
-- The fight is on screen now: let any burst waiting on this side begin.
--
-- Split from arm because the two moments are genuinely different and were
-- conflated twice. Arming happens when the OCCUPANT changes, which is during
-- the transition; the burst may only start once the transition is OVER and
-- there is somebody watching. Between them it sits at zero.
function ShinyFx.release(side)
local s = live[side]
if s and s.pending then
s.pending = nil
if ShinyFx.debug then
ShinyFx.debug.released = (ShinyFx.debug.released or 0) + 1
end
end
end
function ShinyFx.clear(side)
if ShinyFx.debug and side and live[side] then
ShinyFx.debug.cleared = (ShinyFx.debug.cleared or 0) + 1
end
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]
-- a pending burst does not age: it is waiting for the scene to draw it
-- for the first time, which is when its life actually begins (see arm)
if s and not s.pending 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.
-- Why a burst did not draw, for a driver to read back. Rendering faults are
-- invisible to the test suite and this one has four separate ways to be a
-- no-op, all of them silent.
ShinyFx.debug = { calls = 0, noArena = 0, noImage = 0, noMesh = 0,
noLive = 0, quads = 0, armed = 0, cleared = 0 }
function ShinyFx.draw(arena, groundY, pull)
local dbg = ShinyFx.debug
dbg.calls = dbg.calls + 1
if not arena then dbg.noArena = dbg.noArena + 1 return end
local img = starImage()
if not img then dbg.noImage = dbg.noImage + 1 return end
local mesh = BattleBillboard.mesh()
if not mesh then dbg.noMesh = dbg.noMesh + 1 return end
if not (live.player or live.enemy) then
dbg.noLive = dbg.noLive + 1
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
-- A pending burst is not drawn at all. It is waiting for the fight to be
-- ON SCREEN, which is not the same as the scene being drawn: the battle
-- renders underneath the transition wipe for a second or so first, and a
-- burst started there spends its whole life behind it. Stadium.release
-- is what says the wipe is done.
if s and s.pending then s = nil end
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)
-- every distance measured off THIS Pokemon (see the header)
local m = size[side]
local mh = (m and m.h) or ShinyFx.DEFAULT_HEIGHT
local mr = (m and m.r and m.r > 0 and m.r) or ShinyFx.DEFAULT_RADIUS
local ringX = max(mr * ShinyFx.RING_X_FRAC, mh * ShinyFx.RING_X_MIN)
local ringY = mh * ShinyFx.RING_Y_FRAC
local starK = mh * ShinyFx.SIZE_FRAC
-- open from clear of the body, not from a point (see RING_START)
local grow = ShinyFx.RING_START + (1 - ShinyFx.RING_START) * e
local baseY = groundY + mh * ShinyFx.CHEST_FRAC
+ mh * ShinyFx.RISE_FRAC * 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)
-- stars shrink as they fade, and alternate size so the ring reads
-- as scattered rather than as a cog
local k = starK * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
local ox = math.cos(a) * ringX * grow
local oy = math.sin(a) * ringY * grow
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 or 0) + ShinyFx.PULL_BONUS)
dbg.quads = dbg.quads + 1
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
+416
View File
@@ -0,0 +1,416 @@
-- 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 spec = ShinyPalette.forDex(dex)
local fn = ShinyPalette.transform(spec)
if not fn then tintCache[dex] = false; return nil end
local sr, sg, sb, n = 0, 0, 0, 0
if spec.lut then
-- A lookup table answers only the colours that are IN it, so running
-- synthetic references through one returns them untouched and reports a
-- tint of exactly 1 -- i.e. no tint, for the five species whose shiny is
-- the most dramatic in the game. (A shiny Gyarados came out with an
-- ordinary blue pic for precisely this reason.) The table's own entries
-- are the right sample: they are what this Pokemon is actually made of.
for from, to in pairs(spec.lut) do
local fr, fg, fb = floor(from / 65536) % 256, floor(from / 256) % 256,
from % 256
local tr, tg, tb = floor(to / 65536) % 256, floor(to / 256) % 256,
to % 256
-- guard the near-black entries: a ratio against 2 is noise, and a
-- handful of them would swamp the mean
if fr > 24 and fg > 24 and fb > 24 then
sr = sr + tr / fr
sg = sg + tg / fg
sb = sb + tb / fb
n = n + 1
end
end
end
-- A slide: measure it against the colour this Pokemon is mostly MADE of.
--
-- Averaging over a balanced set of references does not work, and the
-- reason is worth keeping: a hue rotation moves red toward cyan and cyan
-- toward red, so over a symmetric wheel the ratios cancel and every
-- species reports a tint of 1. Charizard and Ponyta both came back with no
-- tint at all that way. One real body colour, rotated, is the whole
-- answer.
if n == 0 and spec.dom then
local dr = floor(spec.dom / 65536) % 256
local dg = floor(spec.dom / 256) % 256
local db = spec.dom % 256
if dr > 12 and dg > 12 and db > 12 then
local r, g, b = fn(dr, dg, db)
sr, sg, sb, n = r / dr, g / dg, b / db, 1
end
end
if n == 0 then
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
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
-- A transform for PALETTE colours rather than texture texels.
--
-- The two are not the same job, and using the texel transform on a palette
-- quietly does nothing for five species. A lookup table answers only the
-- colours that are in it -- the ones its model is painted with -- and the
-- engine's ADVANCED palettes are a different set of colours entirely
-- (BLUEMON's blue is not any blue on the Gyarados model). Asked to shift a
-- palette, the table therefore returns it unchanged, and the most dramatic
-- shiny in the game comes out identical.
--
-- So: slide species use the slide, which is defined on all colours. Table
-- species fall back to their tint multiplier, which IS derived from the
-- table and does carry its direction.
function ShinyPalette.paletteTransform(dex)
local spec = ShinyPalette.forDex(dex)
if not spec then return nil end
if not spec.lut then return ShinyPalette.transform(spec) end
local t = ShinyPalette.tintFor(dex)
if not t then return nil end
return function(r, g, b)
return floor(min(255, r * t[1]) + 0.5),
floor(min(255, g * t[2]) + 0.5),
floor(min(255, b * t[3]) + 0.5)
end
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
+227
View File
@@ -0,0 +1,227 @@
-- 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
-- The summary PIC, through its PALETTE.
--
-- Recolouring the sprite's pixels here does NOT work, and it is worth
-- writing down why rather than leaving it to be re-attempted: the summary
-- art is four-shade DMG grey, and the screen's colour is applied
-- afterwards by the palette pass over the finished frame. Whatever RGB is
-- put in the ImageData is remapped away by it. The colour of that pic
-- lives in the palette and nowhere else, so the palette is what has to
-- move. (Tried it, shot it, reverted it.)
--
-- This is the ADVANCED-palette answer, and it is better than a multiply:
-- SetPal_StatusScreen hands the pic zone the species palette
-- (PaletteFX.monPal), and sgbPalettes is a method on the MENU, so unlike
-- the battle pic's image cache it knows which individual is on screen.
-- Running those four colours through the species' own shiny transform
-- gives the summary a genuinely recoloured Pokemon -- brightening
-- included, which a draw-colour multiply cannot do.
--
-- Only ZONE entries are touched. The first palette the engine returns is
-- the whole-screen HP-bar one, and rotating that would recolour the text.
local innerPal = SummaryMenu.sgbPalettes
if type(innerPal) == "function" then
function SummaryMenu:sgbPalettes(game, ...)
local out = innerPal(self, game, ...)
if type(out) ~= "table" or not Shiny.isShiny(self.mon) then return out end
local def = game and game.data and game.data.pokemon
and game.data.pokemon[self.mon.species]
local fn = def and def.dex
and ShinyPalette.transform(ShinyPalette.forDex(def.dex))
if not fn then return out end
for _, z in ipairs(out) do
if type(z) == "table" and z.w and z.h and type(z.colors) == "table" then
-- copied, never mutated in place: monPal hands back the dataset's
-- own palette table, and writing through it would recolour every
-- Pokemon of the species everywhere for the rest of the process
local cols = {}
for i, c in ipairs(z.colors) do
if type(c) == "table" and c[1] then
local r, g, b = fn(c[1], c[2], c[3])
cols[i] = { r, g, b }
else
cols[i] = c
end
end
z.colors = cols
end
end
return out
end
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
+46 -3
View File
@@ -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,46 @@ 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
-- how big this Pokemon actually is, so a shiny's sparkle can be sized to
-- it rather than to a constant that is wrong for most of the dex (see
-- the header of ShinyFx). Pushed every frame: the model can arrive a
-- frame or two after the burst is armed, and a send-out is still growing
-- while it plays.
if mon.rig and mon.model then
ShinyFx.setMetrics(side, mon:worldHeight(), mon:worldRadius())
else
ShinyFx.setMetrics(side, nil)
end
-- and let a waiting sparkle GO, once the fight is actually the thing on
-- screen. The battle draws underneath the transition wipe for about a
-- second before that, and a burst released then plays out its whole life
-- behind it -- armed, drawn, counted, and never seen, which is exactly
-- how this looked when it was keyed on the scene drawing instead.
local g = game()
local top = g and g.stack and g.stack:top()
if top == battle then ShinyFx.release(side) 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
View File
@@ -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
+64 -11
View File
@@ -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
@@ -182,22 +189,46 @@ local function shipped()
return true
end
-- Whether the STADIUM rungs can be offered at all: either the packs have been
-- built from the player's ROM, or the mod folder already carries a set.
-- Whether the packs on disk can be READ, even if they are not current.
--
-- Format and count, but deliberately NOT rev. The distinction matters on an
-- upgrade: a rev bump means the packs are out of date, not that they are
-- unreadable, and treating the two the same is what would make the STADIUM
-- rungs disappear off the options row for anyone whose cache predates it.
-- Losing the recolour until a rebuild is a blemish; losing the mode is not.
function StadiumInstall.usable()
local m = readMarker()
return (m ~= nil and m.format == StadiumInstall.FORMAT
and m.count == StadiumInstall.COUNT) and true or false
end
-- Whether the STADIUM rungs can be offered at all: the packs have been built
-- from the player's ROM (current or merely readable), or the mod folder
-- already carries a set.
function StadiumInstall.available()
if StadiumInstall.ready() then return true end
if StadiumInstall.usable() then return true end
return shipped()
end
-- Whether there is work to do: something to build from, and nothing usable
-- yet.
-- Whether there is work to do: a ROM to build from, and no CURRENT set.
--
-- A checkout that already carries a set is NOT pending. Building anyway would
-- be correct and would also mean a ten-second loading screen on the first run
-- of every checkout, to arrive at the files that were already sitting there.
-- Keyed on ready() rather than available(), and that is the whole upgrade
-- story. It used to short-circuit on available(), which meant a checkout
-- carrying assets/stadium was never pending -- so when REV went to 3 for the
-- shiny variants, such a machine did not rebuild, was not asked to, and
-- quietly kept serving the old set: every shiny Pokemon drawn in its
-- ordinary colours, with nothing on screen to say why. That is exactly what
-- happened here, and it took a driver run sitting at "idle 0/151" to notice.
--
-- The cost this trades away is real and was the original reason: a checkout
-- with a ROM now spends one loading screen rebuilding a set it already had
-- files for. Once. After that ready() is true and it is not pending again --
-- and what it buys is that a rev bump actually reaches the people it was
-- bumped for.
function StadiumInstall.pending()
if StadiumInstall.available() then return false end
return StadiumInstall.romPresent()
if not StadiumInstall.romPresent() then return false end
return not StadiumInstall.ready()
end
function StadiumInstall.forget()
@@ -211,12 +242,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
View File
@@ -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
+88 -22
View File
@@ -62,25 +62,46 @@ 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)
local install = V.require("StadiumInstall")
local mod = V.mod
local haveShipped = false
if mod and mod.read then
local okS, b = pcall(mod.read, mod, packName(StadiumPack.DIR, species, shiny))
haveShipped = okS and type(b) == "string" and #b > 4
end
-- A CURRENT cache always wins. A stale one (readable, but built by an older
-- extractor) wins only when there is no shipped set to prefer instead --
-- that ordering is what stops a cache from an extractor rev we have since
-- fixed shadowing good files, while still leaving something on screen for a
-- player whose only copy IS that cache. A half-written folder is caught by
-- the marker and satisfies neither.
if love and love.filesystem and love.filesystem.getInfo
and V.require("StadiumInstall").ready() then
and (install.ready() or (install.usable() and not haveShipped)) then
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
if okInfo and info then
local ok, bytes = pcall(love.filesystem.read, rel)
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
end
end
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 +495,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 +560,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 +620,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
+73 -1
View File
@@ -26,6 +26,15 @@
-- in closed form -- see footprint() for the derivation, which is three
-- lines of algebra and no tuning at all.
--
-- AND THE GROUND IS NOT THE PICTURE. The trapezoid is where the frame's
-- rays LAND; what is drawn is what stands on it, and a tree at the bottom
-- of the screen has its feet south of the row its top is seen on. Cut to
-- the trapezoid alone, the box takes that tree away whole -- the cut is by
-- column, so a base one pixel outside loses the whole height -- and the
-- bottom of the frame reads as a bite taken out of the scenery. So the
-- south edge is walked back down the bottom ray by the tallest thing that
-- can stand there; see lift().
--
-- The CUT is still a rectangle (the shader's box kind), so what is stored
-- is the trapezoid's bounding rect: never narrower than the picture, so it
-- can never take a bite out of it. It is off-centre in z, because the
@@ -105,6 +114,63 @@ ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
-- the row, so a player who can see the seam can push it away.
ViewBox.MAX_REACH = 6
-- ------- the geometry standing on the ground it frames
--
-- The footprint is where the frame's rays hit the GROUND, and the ground is
-- not what the picture is made of. A tree is most of a hundred world pixels
-- tall, and a point that high up on the BOTTOM edge's own ray sits south of
-- where that ray lands -- nearer the eye, because the ray is coming down. So
-- the bottom of the screen is full of things whose feet are outside the
-- ground trapezoid, and a box cut to the trapezoid alone takes them away
-- whole: the shader cuts a fragment by the column it stands in (Voxel3D's
-- dioramaCull is unbounded upward, deliberately, so a cut never takes the
-- tops off trees), so a tree one pixel south of the edge loses its whole
-- height at once. That is a bite along the bottom of the picture -- a row of
-- trees cut through by the frame's own edge, with the ground behind them
-- showing.
--
-- HEIGHT is the tallest thing standing on that ground, and it is the sun
-- pass's own figure for the same reason it needs one: how far outside the
-- ground it fits can something still reach the picture? Kept here rather
-- than read across so this file's cut does not move when the light's
-- frustum is retuned; they answer to the same world either way.
ViewBox.HEIGHT = 160
-- And a tile of slack on top, at every pitch. The cut's south edge would
-- otherwise land on the frame's own bottom row at the rungs where the term
-- below is zero, which is a hard edge (see FADE_FRAC) balanced on the
-- pixel it is drawn at -- a supersampled frame (lib/AntiAlias) resolves
-- half of it. One tile is cheap and no cut this file makes should be
-- decided by a rounding.
ViewBox.SOUTH_PAD = 16
-- How much further south than the ground it lands on the bottom edge of the
-- frame can still show, in world pixels.
--
-- At sy = -1 the ray direction (see footprint) is
--
-- d = (0, -(cos a + tanY sin a), -(sin a - tanY cos a))
--
-- in (x, y, z) with y up and -z north, so climbing it costs
--
-- (sin a - tanY cos a) / (cos a + tanY sin a)
--
-- of south per world pixel of height. Zero at and below atan(tanY) -- about
-- 26 degrees with FOCAL 1, where the ray is shallower than the frame's own
-- half-angle and a RAISED point lands north of the ground hit, which no cut
-- can lose -- a tile and a half at 35, four at 50, and a good eleven at 75,
-- where the eye is nearly level and a tree is nearly all of what is under
-- the bottom of the frame.
function ViewBox.lift(a)
local Voxel = V.require("VoxelState")
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
local ca = math.max(math.cos(a or 0), 1e-3)
local sa = math.max(math.sin(a or 0), 0)
local rise = (sa - tanY * ca) / (ca + tanY * sa)
if rise <= 0 then return 0 end
return ViewBox.HEIGHT * rise
end
-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
-- so a hard edge across one is a lie about what is being looked at. Flat,
@@ -247,8 +313,14 @@ function ViewBox.frame(cx, cy, vw, vh, level)
if left <= 0.001 then return nil end
frac = frac / left
local Voxel = V.require("VoxelState")
local angle = Voxel.angle or 0
local north, south, side = ViewBox.footprint(
Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288))
angle, vw, vh, ViewBox.MAX_REACH * (vh or 288))
-- the ground the bottom edge lands on is not the southernmost thing under
-- it: what STANDS there reaches into the frame from further south (see
-- lift). Added before the row's multiplier, so FIT carries it too -- it is
-- a correction to the honest answer, not margin around it.
south = south + ViewBox.lift(angle) + ViewBox.SOUTH_PAD
north, south, side = north * frac, south * frac, side * frac
-- The rectangle around it. Off-centre in z, because the trapezoid is:
-- the camera looks NORTH from south of its focus, so there is far more
+14 -1
View File
@@ -236,8 +236,21 @@ local SHADER = [[
// (An earlier CPU version translated along the central view axis,
// which preserved only the screen centre and made off-centre sprites
// and grass swim against the ground while the camera scrolled.)
//
// NEVER PAST THE EYE, which is the one way this can stop being a pure
// depth bias: a vertex nearer the lens than `pull` is carried through
// it and out the other side, where the projection turns inside out and
// the thing lands wherever the far side of the frame happens to be --
// a single tuft of grass smeared across the whole picture. Impossible
// on an orbit rung, where the eye is a screen height away and the pull
// is tens of pixels; ordinary for a staged fight's seat, which stands
// a couple of cells from what it is looking at, and for a first-person
// eye standing in the grass. Half the range is the ceiling: at that
// distance nothing is losing a depth fight the other half would win.
if (pull > 0.0) {
w.xyz += normalize(eye - w.xyz) * pull;
vec3 toEye = eye - w.xyz;
float range = length(toEye);
w.xyz += toEye / max(range, 1e-4) * min(pull, range * 0.5);
}
return vp * w;
}
+6 -1
View File
@@ -15,6 +15,7 @@ local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
local Shadows = V.require("Shadows")
local ChunkMesher = V.require("ChunkMesher")
local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
@@ -1050,7 +1051,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- against the terrain just drawn (a shadow behind a building stays
-- hidden) but never depth-writing, so the grass pass at the end of the
-- frame still wins its feet-overdraw fights.
if not Voxel3D.shadowsActive() then
--
-- Not with the SHADOWS row off, though: that is a player saying no
-- shadows, and standing the fallback in would answer a machine that
-- cannot have them (see lib/Shadows).
if Shadows.enabled() and not Voxel3D.shadowsActive() then
Voxel3D.beginShadows()
for _, p in ipairs(posed) do
drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
+285 -176
View File
@@ -88,15 +88,22 @@ local WorldCurve = V.require("WorldCurve")
local ViewBox = V.require("ViewBox")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
local Shiny = V.require("Shiny")
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")
local ForestAtmos = V.require("ForestAtmos")
local Shadows = V.require("Shadows")
local AntiAlias = V.require("AntiAlias")
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local CamControl = V.require("CamControl")
local VR = V.require("VR")
-- the mod's settings menus: the categories, the screens they open, and the
-- red ink that marks this mod's one row on the engine's OPTIONS list
local SettingsMenu = V.require("SettingsMenu")
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
-- every hook; the other four are the gun, the crowd, the readout and the
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
@@ -448,47 +455,60 @@ local function stagedBattles()
return OverworldBattle.enabled()
end
-- ------- this mod's settings, grouped the way the menus present them
--
-- One entry per setting: the ModSetting itself, the help text the mod
-- manager's page carries, and the fields that decide where it is offered.
--
-- cat which of SettingsMenu's categories the row lives on. The table is
-- kept in category order as well, so the mod manager's own page --
-- which has no categories to give and lists every row flat -- at
-- least keeps related settings next to each other.
-- when a predicate. The row is off the menu entirely while it answers
-- false, because a row that decides nothing reads as a broken mod.
-- full the row SURVIVES the FULL preset. FULL owns the look, so a row
-- goes with it by default; `full` marks the ones that were never
-- about the look. SettingsMenu leans on this and needs no rule of
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
-- category empties out under FULL and takes itself off the menu.
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
-- ------- the top-level menu -- settings that are about the GAME
--
-- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
-- itself rather than inside one of the four categories, which is right
-- here: the categories are the diorama, the fights, what the look costs
-- and the headset, and how often a shiny appears is none of those.
--
-- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
-- an encounter rate is a rule of the game. A player inside FULL must be
-- able to reach it, and FULL must never set it.
{ Shiny.setting,
"How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
.. "rate, and every rung below it is twice as often as the one above.",
cat = SettingsMenu.ROOT, full = true },
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
cat = "world" },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style. 1 is a "
.. "hint of roll at the frame edges and 2 is the classic read; 3 is as "
.. "far as it goes before the horizon closes over ground you can still "
.. "walk into. 4 and 5 are past that on purpose and they are for a "
.. "headset's DIORAMA, where the world is a model being looked at "
.. "rather than walked around in -- 5 curls it into a half sphere, a "
.. "town on top of its own little planet." },
"Bends the world down over the horizon, until a town sits on top of its "
.. "own little planet.",
cat = "world" },
{ ViewBox.setting,
"How much of the map the camera bothers to draw. FIT is exactly the "
.. "ground on screen and no more -- the shape a tilted camera really "
.. "frames, which reaches well north of you and flares wide out there, "
.. "not the square the flat game shows. So a connected map that falls "
.. "entirely outside it is skipped before it is drawn, terrain, water, "
.. "grass and shadows together, which is most of the frame's geometry "
.. "at the high rungs. Below about 63 degrees that is all the row does "
.. "and the picture is untouched. At 75 the camera can see all the way "
.. "to the horizon, so something has to name a distance: FIT is the "
.. "closest, WIDE through WIDEST push the world's edge further out, "
.. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are "
.. "standing in the world there -- and the box opens out and away as "
.. "the camera dives in." },
"How far out the camera bothers to draw, which only changes the picture "
.. "above about 63 degrees where the horizon comes into view.",
cat = "world" },
{ Water.setting,
"Reflections on water. FULL adds screen-space reflections of the "
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
.. "the sun and the moon alone, which is most of the look for a "
.. "fraction of the cost." },
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
"The air of the deep woods (Viridian Forest): a ground haze, and "
.. "volumetric light let down through the unseen canopy overhead -- "
.. "gold spears of sun by day, silver moon rays at night, pollen "
.. "drifting through the beams and fireflies once they cool. LOW "
.. "keeps the haze, halves the beam march and stands the particles "
.. "down. On a phone the row offers LOW alone: the beams need a "
.. "depth texture the pass can read back, and no mobile driver here "
.. "grants one.",
full = true },
"Reflections on water: SKY is the sun, moon and sky alone, and FULL "
.. "adds the shoreline and trees behind it.",
cat = "world" },
{ DayNight.setting,
"What time it is outdoors -- pinned to an hour, running on a ten-minute "
.. "cycle, or synced to the clock on your wall.",
cat = "world" },
-- ------- BATTLES -- what a fight is drawn over, and how it is played
--
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
-- knobs; what a battle is drawn over, and how it is framed, are not that.
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
@@ -496,82 +516,61 @@ local SETTINGS = {
-- and forbids back sprites (backPinned answers false), so both rows
-- decide nothing there and a dead switch on the menu reads as broken.
{ OverworldBattle.setting,
"Fight in three dimensions, shot over the shoulder with a slow parallax "
.. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM "
.. "replaces them with the Pokemon Stadium battle models, animated, "
.. "playing the animation the move being used actually calls for. A "
.. "stages the fight on the MAP -- the nearest clear ground, in that "
.. "place's own weather and light; B stands it on two discs against the "
.. "sky instead, which works everywhere, including the caves and shop "
.. "floors that have nowhere to stage a fight. The STADIUM rungs only "
.. "appear once the models have been built, and building them needs a "
.. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
.. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
.. "other version works: the reader is keyed to that one cartridge.",
"Fights staged in 3D over your shoulder, on the map or on discs against "
.. "the sky, as cards or Stadium's animated models.",
cat = "battles",
when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
-- and a row that no longer decides anything is worse than no row.
{ OverworldBattle.backSetting,
"Keep your own Pokemon on the battle menu, seen from behind in its "
.. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.",
"Keeps your own Pokemon on the battle menu, seen from behind, instead "
.. "of standing it on the map facing the foe.",
cat = "battles",
when = function() return stagedBattles() and not VR.enabled() end,
full = true },
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
-- the diorama, so the FULL preset neither sets it nor takes it away.
{ LetsGo.setting,
"Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, "
.. "a finger or the right stick to throw the ball at the wild Pokemon "
.. "-- spin it first for a curve -- and land inside the shrinking "
.. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. "
.. "CATCH ONLY changes nothing else: picking a ball in battle simply "
.. "plays the throw. FULL is the whole Let's Go treatment: wild "
.. "encounters open straight in throwing mode (B backs out to the "
.. "classic menu), Poke/Great/Ultra Balls are half price, and a catch "
.. "pays the whole party experience -- scaled by throw quality, first "
.. "throws, new species and your running catch combo. Needs 3D-BTL "
.. "on; anywhere the staged fight cannot stand, balls quietly throw "
.. "the classic way.",
full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
"Pokemon GO-style catching -- flick to throw the ball, with FULL adding "
.. "half-price balls and party experience (needs 3D-BTL).",
cat = "battles", full = true },
-- ------- PERFORMANCE -- what the look COSTS, which is a different question
--
-- All three are `full`, and all three for the same reason: FULL is a preset
-- for the diorama, not a licence to spend whatever the machine it happens
-- to be running on has got. The player decides what their hardware can
-- carry, from inside FULL like anywhere else.
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
"Haze and volumetric light shafts in the deep woods, with pollen in the "
.. "beams by day and fireflies at night.",
cat = "perf", full = true },
-- `full` on AA's reasoning below, and for the same reason: the sun's pass
-- is the most expensive thing in the frame after the geometry, so this is
-- a question about the machine rather than a knob on the diorama, and it
-- has to stay reachable from inside FULL -- which never sets it either.
{ Shadows.setting,
"Real cast shadows from the sun, and the first thing to switch off on a "
.. "phone or an old machine.",
cat = "perf", full = true },
-- Marked `full` for the opposite reason the battle rows are: this is not a
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
-- the diorama, not a licence to spend four times the fill rate on the
-- machine it happens to be running on, so it neither sets this nor takes
-- the row away -- the player decides what their hardware can carry, from
-- inside FULL like anywhere else.
-- knob on the look at all, it is what the look COSTS.
{ AntiAlias.setting,
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
.. "lips, a tree against the sky -- by rendering the diorama larger than "
.. "the window and folding it back down. Every edge in the picture "
.. "softens with them, the tileset's own texels included, so the diorama "
.. "reads smoother rather than sharper. 2X costs half again as many "
.. "pixels in each direction and 4X twice, which makes this the most "
"Smooths the stair-stepped edges of the 3D world, and the most "
.. "expensive row in the mod.",
full = true },
cat = "perf", full = true },
-- ------- VR -- the headset, and the one comfort knob that is only its
--
-- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk.
{ VR.setting,
"PCVR through OpenXR (SteamVR, Oculus, WMR). STANDARD follows the VOXEL "
.. "ladder: the orbit rungs become a tabletop model your head moves "
.. "around, and the 1ST rung stands you inside the world at life size, "
.. "looking where the headset looks. DIORAMA is one presentation "
.. "instead -- the world always a model, cut to a square viewport you "
.. "grab with the grips to carry, turn and open out, with a "
.. "fight arriving as a floating disc of the map. There is no 2D and "
.. "no first person in it, and the left stick's click throws V-CURVE "
.. "to its top rung and back -- which turns the square cut into a ball "
.. "with a dissolved rim, because a bent world has no straight sides. "
.. "DIORAMA-MR is the same with the background keyed green, for a "
.. "mixed-reality capture. "
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
.. "and the mod running from a real folder; without them the row stays "
.. "and the game stays flat, with the reason on the console.",
"PCVR through OpenXR on Windows, either following the VOXEL ladder or "
.. "as a DIORAMA you carry and turn with the grips.",
cat = "vr",
-- on Windows the row stays even when a runtime is missing (the console
-- says why); off Windows -- mobile above all -- there is no VR to have
-- and the row does not exist
@@ -580,17 +579,17 @@ local SETTINGS = {
-- device that is not plugged in decides nothing, and this one is read
-- exclusively by the headset's right stick.
{ VR.smoothTurn,
"Turn smoothly with the right stick instead of snapping 45 degrees a "
.. "flick. OFF by default, and deliberately: a software turn moves the "
.. "world past a head that did not move, which is the most reliable way "
.. "to make somebody ill in a headset. Turn it on if you have your sea "
.. "legs and want the continuity.",
"Turns smoothly with the right stick instead of snapping 45 degrees, "
.. "if you have your sea legs for it.",
cat = "vr",
-- and only under STANDARD: the stick turns a HEAD, and neither diorama
-- mode has the player standing in the world to be turned
when = function() return VR.enabled() and not VR.dioramaMode() end,
full = true },
}
SettingsMenu.define(SETTINGS)
local schema = {}
for _, entry in ipairs(SETTINGS) do
-- the VR rows are absent from the mod manager's page too where the
@@ -767,29 +766,31 @@ do
end
end
-- ------- the mode's rows, kept together
-- ------- the mode's rows, on menus of their own
--
-- The engine splices a pipeline's row in beside TILT, because a display mode
-- belongs with the other display modes; a mod's own ui.options.rows
-- additions land at the END of the list. That left this mod's four rows in
-- two places with unrelated engine rows between them, which reads as two
-- unrelated features rather than one mode with settings.
-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
-- time, so that was four screens of scrolling inside a list that already
-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
-- there past the wireframe and the horizon bend.
--
-- So the plain settings are inserted directly after the last of this mod's
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
-- where the engine already decided display modes go.
local function insertGrouped(out, extra)
local anchor = nil
for i, row in ipairs(out) do
local id = type(row) == "table" and row.id
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
-- Now there is ONE row, and it leads the list. What it opens -- the
-- categories, the screens, and why the split falls where it does -- is
-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
-- display modes, the engine only spliced them beside TILT because it had
-- nowhere better, and TILT is not on the menu any more anyway (see below).
--
-- Two things it takes to move a pipeline row: the engine's descriptor is
-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
-- through its own step function into save.options.pipelines, and rebuilding
-- it here would be a second implementation of something the engine already
-- got right -- and the row is then dropped from the top-level list so it is
-- not in two places at once.
local function captureRow(out, id)
for _, row in ipairs(out) do
if type(row) == "table" and row.id == id then return row end
end
if not anchor then
for _, row in ipairs(extra) do out[#out + 1] = row end
return out
end
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
return out
return nil
end
-- FULL owns the settings that describe the LOOK, so while it is selected those
@@ -859,6 +860,25 @@ local function pinEngineFx(game)
if changed and game.writeOptions then pcall(game.writeOptions, game) end
end
-- ------- the values that follow other values
--
-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
-- that row. Both pins used to be a side effect of the rows hook, which every
-- step on the OPTIONS menu reran -- so they happened whether or not the step
-- was the one that mattered, and nothing had to name them.
--
-- Now a step can happen on the mod's own menu, where no hook runs, or on the
-- mod manager's page, where one never did. So the pinning is a function, and
-- all three routes ask for it.
local function pinDependents(game)
if stagedBattles() then OverworldBattle.forceOG(game) end
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
end
SettingsMenu.setOnChanged(pinDependents)
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
@@ -885,46 +905,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
local full = Voxel.isFull(Pipelines.level("voxel"))
if full then
if Voxel.isFull(Pipelines.level("voxel")) then
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-- horizon bend, the blur, the hour -- so those come off the menu and
-- DAYTIME is held at SYNC while its row is unreachable.
-- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
-- row is unreachable. The rows themselves come off inside SettingsMenu,
-- which is where they live now: T-SHIFT with the wireframe and the bend,
-- and each of them by the same `full` rule rather than by name.
DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do
-- Two things decide whether a row is offered.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
-- applyFull); it does not hold them, so leaving them on the menu is the
-- difference between a preset and a lock.
--
-- And a row whose own switch is off the table this frame (BACK SPRITES,
-- which needs a staged fight to be about) is left off with it. The mod
-- manager's page carries every one of them either way.
local offered = (entry.full or not full)
and (not entry.when or entry.when())
if offered then extra[#extra + 1] = entry[1]:row() end
-- The two pipeline rows move INTO the mod's own root menu: captured as the
-- engine built them, then dropped from here so they are not in two places.
local captured, voxelRow = {}, nil
for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
local row = captureRow(out, id)
-- a pipeline the registry refused is simply not there, and the menu says
-- so by not offering it rather than by offering a hole
if row then captured[#captured + 1] = row end
if id == "pipeline:voxel" then voxelRow = row end
dropRow(out, id)
end
-- and the ROM import, which is an ACTION and not a setting: there is no
-- rung to store, nothing for the mod manager's page to persist and nothing
-- to restore on the next boot, so it is appended here rather than living in
-- SETTINGS. nil on a platform with no file dialog, which takes it off the
-- menu rather than offering a button that cannot do anything.
-- On EVERY platform. Where there is no file dialog it says WHERE? and
-- shows the folder to put the cartridge in, which is the one thing a
-- player on a phone could not otherwise find out -- the row used to vanish
-- there, which reads as the feature being missing rather than manual.
local okPick, importRow = pcall(function()
return V.require("StadiumRomPick").row()
end)
if okPick and importRow then extra[#extra + 1] = importRow end
return insertGrouped(out, extra)
SettingsMenu.setPipelineRows(captured)
-- ------- one row, and it leads the list
--
-- At the TOP rather than spliced in beside the display modes it used to sit
-- with. This is a mod that replaces the whole look of the game, and a player
-- who installed it and went looking for its settings should not have to
-- scroll to find out where they went -- least of all past the engine rows it
-- has quietly taken away.
--
-- Inserted after next() has run, so it leads every OTHER mod's rows too. The
-- second line is VOXEL's own value function, which makes the row say what
-- the mode is currently doing without opening it -- and reuses the engine's
-- label ladder rather than restating it.
table.insert(out, 1, {
id = SettingsMenu.id(SettingsMenu.ROOT),
label = SettingsMenu.ROOT_LABEL,
value = voxelRow and voxelRow.value or nil,
-- `activate` and not `step`: the engine fires activate on A alone, and a
-- row that OPENS something should not also answer Left and Right
-- (src/ui/OptionsMenu.update).
activate = function(g)
g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT))
end,
})
return out
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -935,14 +959,11 @@ mod.events:on("mod.options_changed", function(payload)
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
-- the OPTIONS row does. The manager persists its own value; this is the one
-- that has to follow it.
if stagedBattles() then OverworldBattle.forceOG() end
-- and DAYTIME changed from the manager's page while FULL owns it snaps
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
-- not, and FULL's pin must hold against both
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
-- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
-- straight back to SYNC -- that row is off the mod's menus under FULL, but
-- the manager's page carries every setting unconditionally, and the pin has
-- to hold against both.
pinDependents()
end)
-- ------- keeping the geometry in step with the world
@@ -1042,30 +1063,55 @@ end)
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
-- clamped rather than reset, so it stays on the row it was just used on
-- instead of jumping to the top when the list below it shortens.
--
-- Held on the INSTANCE rather than compared across one call of update, and
-- that is not a tidying: those three rows live in a SUBMENU now, and the
-- stack only ticks its top state (src/core/StateStack.update). So the step
-- that changes them happens while this menu is suspended and a
-- before/after pair taken around inner() would both be read after the fact
-- and always agree. A signature that outlives the suspension does not.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local OptionRows = require("src.ui.OptionRows")
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
local innerPalettes = OptionsMenu.sgbPalettes
local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil
end
-- What the row LIST depends on: whether FULL is selected (it owns the
-- rows that describe the look), and the two switches that give and take
-- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
-- both battle rows while it is on. Only the FULL-ness of the voxel level
-- matters, so stepping 35 to 50 is not a change.
local function signature()
return string.format("%s|%s|%s",
tostring(Voxel.isFull(Pipelines.level("voxel"))),
tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
end
-- Stamped where the ROWS are built, which is the thing the signature is a
-- signature OF. Read lazily on the first update instead and a menu opened
-- before the change and updated after it would compare the new state
-- against itself and never rebuild.
local innerNew = OptionsMenu.new
function OptionsMenu.new(game, opts)
local menu = innerNew(game, opts)
menu.dramaticShapeSig = signature()
return menu
end
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
-- the VR row hides the two battle rows while it is on, so stepping
-- it changes the LIST exactly the way 3D-BTL does
local hadVR = VR.enabled()
local wasOn = idAt(self, self.index)
local before = self.dramaticShapeSig or signature()
inner(self, dt)
local after = Pipelines.level("voxel")
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles
or VR.enabled() ~= hadVR then
local after = signature()
self.dramaticShapeSig = after
if before ~= after then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
@@ -1079,6 +1125,36 @@ do
end
end
-- ------- and the mod's own row is red
--
-- Why this is a palette zone and not love.graphics.setColor -- twice over
-- -- is written out in lib/SettingsMenu.lua, next to the code that builds
-- the palette. The short of it: setColor picks a SHADE on this screen and
-- the zone picks the COLOR.
--
-- Addressed by SLOT, because the row scrolls: it leads the list, so it is
-- normally the top box, but a player who scrolls past it must not leave a
-- red band behind on whatever takes its place. Searched by id rather than
-- assumed to be row 1 for the same reason -- another mod's hook running
-- after ours could put something above it.
function OptionsMenu:sgbPalettes(game)
local zones = innerPalettes and innerPalettes(self, game) or nil
local scroll = self.scroll or 0
for slot = 1, OptionRows.VISIBLE do
local row = self.rows and self.rows[scroll + slot]
if type(row) == "table"
and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
local zone = SettingsMenu.rowZone(game and game.data, slot)
if zone then
zones = zones or {}
zones[#zones + 1] = zone
end
break
end
end
return zones
end
OptionsMenu.dramaticShapeFullHook = true
end
end
@@ -1091,6 +1167,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.
+725 -85
View File
@@ -119,6 +119,46 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
local Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- ------- reaching the rows now that they are on menus of their own
--
-- The ui.options.rows hook contributes ONE row to the engine's list, and that
-- row opens the mod's own screens. So a test asks SettingsMenu.rows -- which
-- is pure on purpose, precisely so that asking what is on a menu does not mean
-- pushing one -- rather than scanning the hook's output for a setting.
--
-- Keyed by id, because that is the one thing about a row that is a promise.
-- The old positional lookups (hookedRows[7] is 3D-BTL) pinned an ORDER the
-- menus are now free to change, and every one of them had to be rewritten to
-- get here; none of them was testing the order on purpose.
--
-- One table rather than three locals: this file is one Lua chunk, a chunk has
-- 200 local slots, and it is already spending them carefully (see the scoped
-- sections throughout).
local Menus = { lib = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsMenu") }
function Menus.rows(catId, game)
local ids = {}
for _, row in ipairs(Menus.lib.rows(catId, game or { data = Data })) do
ids[row.id] = row
end
return ids
end
-- every row the mod offers ANYWHERE: the root menu plus all four categories.
-- What most of this file wants, since "is this setting on a menu at all" is
-- the question the `when` and `full` gates actually answer.
function Menus.offered(game)
local ids = Menus.rows(Menus.lib.ROOT, game)
for _, cat in ipairs(Menus.lib.CATEGORIES) do
for id, row in pairs(Menus.rows(cat.id, game)) do ids[id] = row end
end
return ids
end
-- the id of a category's own row on the root menu
function Menus.catId(catId)
return Menus.lib.id(catId)
end
-- ------- FULL is a preset that owns the rows describing the LOOK
--
@@ -131,31 +171,67 @@ local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
-- go, which is what makes it a preset rather than a lock.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
-- scoped, like the sections below: this file is one Lua chunk and a chunk has
-- 200 local slots, so a section that wants four borrows them
do
local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" } })
local fullIds = {}
for _, row in ipairs(fullRows) do fullIds[row.id] = true end
T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
T.check(not fullIds["pipeline:tiltshift"],
-- what the hook leaves on the ENGINE's list: one row, and the pipeline rows
-- gone from it because they have moved onto the mod's own root menu
T.check(fullIds[Menus.catId(Menus.lib.ROOT)],
"the mod contributes exactly one row to the OPTIONS menu")
T.check(not fullIds["pipeline:voxel"],
"and takes VOXEL off the engine's list -- it lives on the mod's menu now")
T.check(not fullIds["pipeline:tiltshift"], "and T-SHIFT with it")
T.eq(fullRows[1].id, Menus.catId(Menus.lib.ROOT),
"and it LEADS the list: a mod that replaces the look of the whole game "
.. "should not make the player scroll to find out where its settings went")
local fullRoot = Menus.rows(Menus.lib.ROOT)
local fullOffered = Menus.offered()
T.check(fullRoot["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
T.check(not fullRoot["pipeline:tiltshift"],
"FULL takes T-SHIFT off the menu -- it owns the blur")
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
T.check(not fullOffered["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullOffered["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullOffered["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
T.check(not fullOffered["DRAMATIC_SHAPE:water"], "and WATER")
T.check(not fullOffered["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
-- and with all five of them gone the category has nothing left in it, so it
-- takes ITSELF off the root menu. No rule anywhere says "hide 3D WORLD under
-- FULL" -- that category is exactly the rows FULL owns, and an empty category
-- is not offered.
T.check(not fullRoot[Menus.catId("world")],
"so 3D WORLD is empty, and an empty category is not offered")
-- but the battle rows survive it: they are not knobs on the look, and FULL
-- sets them once rather than holding them, so a player who wants the classic
-- back sprite (or no staged fights at all) can still say so from inside FULL
T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
T.check(fullOffered["DRAMATIC_SHAPE:battles"],
"3D-BTL is still on the menu under FULL")
T.check(fullOffered["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
T.check(fullRoot[Menus.catId("battles")],
"and the category that carries them is still offered")
-- and AA, for the opposite reason: it is not a knob on the look at all, it is
-- what the look COSTS, and only the player knows what their machine can carry
T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
T.check(fullOffered["DRAMATIC_SHAPE:aa"],
"and AA, which FULL neither sets nor owns")
-- SHADOWS on the same reasoning: the sun's pass is what the look costs, and
-- a preset for the diorama does not get to spend a second draw of the whole
-- world on a machine that cannot carry one
T.check(fullOffered["DRAMATIC_SHAPE:shadows"],
"and SHADOWS, the other row that is a question about the hardware")
T.check(fullRoot[Menus.catId("perf")],
"PERFORMANCE is the category for exactly that question, and it survives too")
-- VR survives FULL on AA's reasoning: whether a headset is on the desk is
-- not the diorama's to decide
T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question")
T.check(fullOffered["DRAMATIC_SHAPE:vr"],
"and VR, likewise the hardware's question")
end
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
-- be reached while FULL owns it, so a value changed underneath (the mod
@@ -285,8 +361,14 @@ local fxIds = {}
for _, row in ipairs(fxRows) do fxIds[row.id] = true end
T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu")
T.check(not fxIds["gbcfx"], "and so is GBC FX")
T.check(fxIds["colors"] and fxIds["pipeline:voxel"],
T.check(fxIds["colors"],
"with every other row the engine offered still on it")
-- VOXEL is off this list too, but for the opposite reason: not taken away,
-- MOVED -- onto the mod's own root menu, which the one remaining row opens
T.check(not fxIds["pipeline:voxel"] and fxIds[Menus.catId(Menus.lib.ROOT)],
"and VOXEL moved onto the mod's own menu rather than being dropped")
T.check(Menus.rows(Menus.lib.ROOT)["pipeline:voxel"],
"-- where it is still offered")
T.eq(fxGame.save.options.tilt, 0,
"a save that had TILT on is pinned back to off, not left on with no row")
@@ -305,32 +387,72 @@ T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
Pipelines.setLevel("voxel", 2)
end
-- ------- and off FULL, the rows come back, grouped with the mode
-- ------- and off FULL, the rows come back -- on the mod's own menus
--
-- The engine splices a pipeline row in beside TILT and lands a mod's own
-- additions at the END of the list, which would leave this mode's four rows
-- in two places with unrelated rows between them.
-- What the engine's list carries is ONE row, and it leads. What that row opens
-- is the mod: the two pipeline rows it took off the engine's list, and the
-- four categories.
Pipelines.setLevel("voxel", 2)
do
local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" },
{ id = "void_fill" } })
local order = {}
for i, row in ipairs(grouped) do order[row.id] = i end
T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
"the mode's settings follow its pipeline rows")
-- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6,
"and sit in one unbroken block, not scattered to the end of the list")
T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
"with the engine's own later rows still after them")
-- the engine gave it four rows and got three back: three of ours are gone
-- (tilt dropped, both pipelines moved) and one is added, at the front
-- the engine offered four and two come back: TILT dropped, both pipeline rows
-- moved onto the mod's own menu, and one row added at the front
T.eq(#grouped, 2, "one row for the whole mod, and the engine's own beneath it")
T.eq(grouped[1].id, Menus.catId(Menus.lib.ROOT), "and it leads the list")
T.eq(grouped[2].id, "void_fill",
"with the engine's own rows following in the order it built them")
local root = Menus.rows(Menus.lib.ROOT)
T.check(root["pipeline:voxel"] and root["pipeline:tiltshift"],
"both pipeline rows are on the mod's root menu")
for _, cat in ipairs({ "world", "battles", "perf", "vr" }) do
T.check(root[Menus.catId(cat)],
("off FULL the %s category is offered"):format(cat))
end
-- the split itself: each setting on the category that owns it, and nowhere
-- else. Order inside a category is free to change; membership is the promise.
local WHERE = {
grid = "world", curve = "world", viewbox = "world", water = "world",
daytime = "world",
battles = "battles", battleBack = "battles", letsgo = "battles",
atmos = "perf", shadows = "perf", aa = "perf",
vr = "vr", smoothturn = "vr",
}
for key, cat in pairs(WHERE) do
local id = "DRAMATIC_SHAPE:" .. key
-- smoothturn and battleBack need their own switch on first; they are
-- checked where those switches are, so only assert the ones offered now
if Menus.offered()[id] then
T.check(Menus.rows(cat)[id],
("%s is on the %s menu"):format(key, cat))
end
end
-- the ROM import is an action, not a setting, and it is one-time SETUP, so it
-- sits on the top-level menu where the mod begins rather than two levels down
-- the category it unlocks
T.check(Menus.rows(Menus.lib.ROOT)["DRAMATIC_SHAPE:stadiumRom"],
"and STADIUM ROM is on the top-level menu, not inside a category")
T.check(not Menus.rows("battles")["DRAMATIC_SHAPE:stadiumRom"],
"and only there")
do
local rootRows = Menus.lib.rows(Menus.lib.ROOT, { data = Data })
T.eq(rootRows[#rootRows].id, "DRAMATIC_SHAPE:stadiumRom",
"last on it, because the categories are what the menu is for")
end
end
-- ------- the open menu notices when FULL is stepped onto or off
--
-- OptionsMenu reads its row list every frame but builds it once, so without
-- a rebuild the rows FULL owns stay on screen until the menu is reopened --
-- and stepping OFF FULL never brings them back.
-- A menu reads its row list every frame but builds it once, so without a
-- rebuild the rows FULL owns stay on screen until it is reopened -- and
-- stepping OFF FULL never brings them back.
local OptionsMenu = require("src.ui.OptionsMenu")
local pressed = {}
local menuGame = {
@@ -338,17 +460,30 @@ local menuGame = {
save = { options = { pipelines = {}, modOptions = {} } },
mods = { modOptions = {} },
input = { wasPressed = function(_, k) return pressed[k] or false end },
stack = { pop = function() end },
-- a real stack, because the mod's own menus push and pop on it
stack = {
states = {},
push = function(s, st) s.states[#s.states + 1] = st end,
pop = function(s) s.states[#s.states] = nil end,
top = function(s) return s.states[#s.states] end,
},
writeOptions = function() end,
}
Pipelines.setLevel("voxel", 2)
local menu = OptionsMenu.new(menuGame)
local function rowIndex(m, id)
for i, row in ipairs(m.rows) do if row.id == id then return i end end
end
T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"off FULL the menu opens with the mode's settings on it")
Pipelines.setLevel("voxel", 2)
-- The mod's root menu shows the ENGINE's own pipeline descriptors, captured by
-- the hook on its way past. The sections above fed the hook stub rows, which
-- are enough to test what is dropped but carry no step function -- so hand it
-- the real ones before driving the menu, or a keypress on VOXEL does nothing.
Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
Pipelines.rows(menuGame))
local menu = Menus.lib.new(menuGame, Menus.lib.ROOT)
T.check(rowIndex(menu, Menus.catId("world")),
"off FULL the root menu opens with the 3D WORLD category on it")
-- step the VOXEL row from 15 down to FULL, the way the player would
menu.index = rowIndex(menu, "pipeline:voxel")
@@ -356,10 +491,12 @@ pressed = { left = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"and the rows FULL owns left the OPEN menu at once")
T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them")
T.check(not rowIndex(menu, Menus.catId("world")),
"and the category FULL emptied left the OPEN menu at once")
T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with it")
T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
T.eq(menu.index, rowIndex(menu, "pipeline:voxel"),
"and stayed on the row the player just used")
-- and back off it again
menu.index = rowIndex(menu, "pipeline:voxel")
@@ -367,16 +504,17 @@ pressed = { right = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"and the rows came straight back without reopening the menu")
T.check(rowIndex(menu, Menus.catId("world")),
"and the category came straight back without reopening the menu")
T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
-- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too
-- ------- 3D-BTL owns BATTLE LAYOUT, from a menu the OPTIONS list cannot see
--
-- The row this one takes away sits ABOVE it in the list, so the cursor has to
-- follow the row it was ON rather than the slot it was in -- otherwise the very
-- press that switched staged battles on would leave the cursor a row further
-- down than the player left it.
-- The switch lives on the mod's BATTLES menu now, and the stack ticks its TOP
-- state only (src/core/StateStack.update). So the step happens while the
-- OPTIONS menu underneath is suspended, and a before/after pair taken around
-- one call of its update would be read entirely after the fact and always
-- agree. The signature is held on the instance for exactly this.
do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
Battles.setting:setValue(false, menuGame) -- staged battles off
@@ -385,37 +523,369 @@ Pipelines.setLevel("voxel", 2)
local layoutMenu = OptionsMenu.new(menuGame)
T.check(rowIndex(layoutMenu, "battleLayout"),
"with staged battles off, the engine's BATTLE LAYOUT row is on the menu")
layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles")
local battleMenu = Menus.lib.new(menuGame, "battles")
T.check(not rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
"and BACK SPRITES is off the BATTLES menu, having no staged fight to frame")
battleMenu.index = rowIndex(battleMenu, "DRAMATIC_SHAPE:battles")
pressed = { right = true }
layoutMenu:update(0)
battleMenu:update(0)
pressed = {}
T.eq(Battles.setting:get(), true, "the step switched staged battles on")
T.check(not rowIndex(layoutMenu, "battleLayout"),
"and BATTLE LAYOUT left the open menu with the same keypress")
T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out")
T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"),
T.check(rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
"and BACK SPRITES arrived on the open submenu with the same keypress")
-- it arrives BELOW the row that was used, so this one only holds because the
-- cursor follows the row rather than the slot
T.eq(battleMenu.index, rowIndex(battleMenu, "DRAMATIC_SHAPE:battles"),
"with the cursor still on the row the player just used")
layoutMenu:update(0)
T.check(not rowIndex(layoutMenu, "battleLayout"),
"and the OPTIONS menu underneath drops BATTLE LAYOUT on its next update, "
.. "though the step it is reacting to happened while it was suspended")
end
-- ------- the row OPENS the menus, and B comes back one level at a time
do
menuGame.stack.states = {}
Pipelines.setLevel("voxel", 2)
local hookRow
for _, row in ipairs(Runtime.call("ui.options.rows", function(_, r) return r end,
menuGame, Pipelines.rows(menuGame))) do
if row.id == Menus.catId(Menus.lib.ROOT) then hookRow = row end
end
T.check(hookRow.activate and not hookRow.step,
"the row answers A alone -- `activate` SHADOWS `step` in the engine's own "
.. "dispatch, so a row carrying both would silently ignore Left and Right")
T.eq(hookRow.value(menuGame), "15",
"and its second line is VOXEL's own, so it says what the mode is doing "
.. "without being opened")
hookRow.activate(menuGame)
local root = menuGame.stack:top()
T.check(root ~= nil, "pressing A pushed a screen")
T.eq(#root.rows, #Menus.lib.rows(Menus.lib.ROOT, menuGame),
"carrying the root menu's rows")
T.eq(root:backLabel(), "B BACK SEL HELP",
"and a bottom line naming the two buttons that are not obvious")
T.check(#root:backLabel() <= 18,
"which fits the eighteen characters the line has")
-- the second line of a category row
T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(),
Menus.rows("battles", menuGame)["DRAMATIC_SHAPE:battles"].value(),
"a category one setting speaks for says that setting's rung")
T.eq(root.rows[rowIndex(root, Menus.catId("world"))].value(), "5 SETTINGS",
"and one no single row speaks for counts them instead, which is honest "
.. "rather than arbitrary")
-- into a category, and back out of it a level at a time
root.index = rowIndex(root, Menus.catId("world"))
pressed = { a = true }
root:update(0)
pressed = {}
T.check(menuGame.stack:top() ~= root,
"A on a category opens it, as a screen of its own")
T.eq(menuGame.stack:top().cat, "world", "the one the cursor was on")
pressed = { b = true }
menuGame.stack:top():update(0)
pressed = {}
T.eq(menuGame.stack:top(), root, "B comes back ONE level, to the root menu")
pressed = { start = true }
root:update(0)
pressed = {}
T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does")
end
-- ------- SELECT explains the row the cursor is on
--
-- Every setting has carried a paragraph of help since it was written -- it
-- goes into the schema the mod manager is handed -- and nothing in the engine
-- has ever drawn one. A row says what it IS and what it is SET TO, and no
-- engine row anywhere has room for a third thing.
do
menuGame.stack.states = {}
Pipelines.setLevel("voxel", 2)
local Help = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsHelp")
local menu2 = Menus.lib.new(menuGame, "world")
-- every row on every menu has something to say, the two engine-owned pipeline
-- rows and the ROM import included -- those three keep theirs in SettingsMenu,
-- having no SETTINGS entry of their own to keep it in
for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
T.check(Menus.lib.helpFor(row.id),
("%s has help behind SELECT"):format(row.id))
end
end
T.check(not Menus.lib.helpFor("nothing:here"),
"and a row with nothing to say gets nil rather than an empty box")
-- every character of every one of them has a glyph in the ROM's font.
--
-- Read from the REAL charmap rather than the fixture's, which is a stub with
-- seven entries in it. This is the one place in the mod where prose reaches
-- the screen verbatim, and Font.encode answers a character it does not know
-- with a SPACE and a one-time console warning that nobody is reading -- so a
-- curly quote or a `%` pasted in from somewhere would blank a word and say
-- nothing about it.
do
local ok, font = pcall(dofile, "data/generated/font.lua")
if ok and font and font.charmap then
local glyph = { [" "] = true }
for _, e in ipairs(font.charmap) do
if #e.seq == 1 then glyph[e.seq] = true end
end
local missing = {}
for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
for ch in (Menus.lib.helpFor(row.id) or ""):gmatch(".") do
if not glyph[ch] then missing[ch] = true end
end
end
end
local names = {}
for ch in pairs(missing) do names[#names + 1] = ("%q"):format(ch) end
table.sort(names)
T.eq(#names, 0, "every character of every help string has a glyph in the "
.. "ROM font -- these do not: " .. table.concat(names, " "))
end
end
menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
pressed = { select = true }
menu2:update(0)
pressed = {}
local box = menuGame.stack:top()
T.check(box ~= nil and box ~= menu2, "SELECT opens a box over the menu")
T.eq(box.title, "RENDER DIST", "titled with the row it explains")
T.check(#box.lines > 1, "carrying the row's own description, wrapped")
for _, line in ipairs(box.lines) do
T.check(#line <= 17,
"every line fits between the border and the more-arrow's column")
end
-- ------- every description is ONE SENTENCE, and the whole of it is on screen
--
-- Both halves matter and the second follows from the first. The box is sized
-- to what it holds and anchored to the bottom, so a description that fits is
-- read at a glance with the row it describes still visible above it -- and one
-- that does not turns a glance into a scroll, which is a worse answer to
-- "what does this do" than a shorter sentence would have been.
--
-- One sentence is: ends with a full stop, and has none inside it. The version
-- numbers that would break that naive test ("US 1.0") have no space after the
-- point, which is why the check is for ". " rather than for ".".
do
local long, many = {}, {}
for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
local help = Menus.lib.helpFor(row.id)
if help then
if help:find(". ", 1, true) or not help:find("%.$") then
many[#many + 1] = row.id
end
local probe = Help.new(menuGame, row.label, help)
if probe:maxTop() > 0 then
long[#long + 1] = ("%s (%d lines)"):format(row.id, #probe.lines)
end
end
end
end
T.eq(#many, 0,
"every description is one sentence -- these are not: "
.. table.concat(many, ", "))
T.eq(#long, 0,
"and every one of them fits its box without scrolling: "
.. table.concat(long, ", "))
end
-- the scroll is still there, for a description that outgrows the box despite
-- the check above -- a mod-supplied font with a wider glyph, or a sentence
-- somebody lengthens later. It stops at both ends rather than wrapping.
do
local tall = Help.new(menuGame, "TALL", ("word "):rep(80))
T.check(tall:maxTop() > 0, "a description too long for the box scrolls")
T.eq(tall.top, 0, "opening at the top")
pressed = { up = true }
tall:update()
pressed = {}
T.eq(tall.top, 0, "up at the top stays there")
pressed = { down = true }
tall:update()
pressed = {}
T.eq(tall.top, 1, "down moves one line")
for _ = 1, 200 do
pressed = { down = true }
tall:update()
pressed = {}
end
T.eq(tall.top, tall:maxTop(), "and the end of the text ends the scroll")
T.eq(tall:maxTop(), #tall.lines - tall:bodyRows(),
"with the last boxful still full, not one line stranded at the bottom")
end
-- and every button that could mean "done" closes it -- SELECT included, which
-- is the one a player who just pressed it will reach for
for _, btn in ipairs({ "a", "b", "start", "select" }) do
menuGame.stack.states = {} -- the scrolled box above is still open
menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
pressed = { select = true }
menu2:update(0)
pressed = {}
T.check(menuGame.stack:top() ~= nil, "the box is open")
pressed = { [btn] = true }
menuGame.stack:top():update()
pressed = {}
T.eq(menuGame.stack:top(), nil, ("%s closes it"):format(btn:upper()))
end
-- a title strips the "..'" a category row wears to say it opens something:
-- inside the box it has already opened, the dots are a promise about a press
-- that has been made
T.eq(Help.new(menuGame, "3D WORLD..", "x").title, "3D WORLD",
"a category's box is titled without the dots that meant `opens something'")
-- and the wrap never strands a word it cannot fit
T.eq(#Help.wrapped(("y"):rep(40))[1], 17,
"a word longer than the line is broken across lines rather than cut")
end
-- ------- the mod's row is RED, which is a palette zone and not a color
--
-- setColor cannot do it here twice over: the glyph atlas is black ink and LOVE
-- tints multiplicatively, and the palette shader keys on the red channel alone
-- so a red pixel would land in the LIGHTEST slot (see lib/SettingsMenu.lua).
-- What a zone changes is which color the SHADE the text was drawn in comes out
-- as, so what there is to assert is where the band sits and which slot carries
-- the ink.
do
local PaletteFX = require("src.render.PaletteFX")
-- the fixture dataset ships no palettes; stand MEWMON up, since the point is
-- that the red palette is MEWMON COPIED rather than four invented colors
Data.palettes = { palettes = {
MEWMON = { { 255, 239, 255 }, { 255, 140, 140 }, { 80, 80, 255 }, { 0, 0, 0 } },
} }
local red = Menus.lib.redPalette(Data)
T.eq(#red, 4, "the red palette is a palette like any other, four colors")
T.eq(red[1][2], 239,
"and its paper is MEWMON's own -- Red, Blue and Yellow ship different "
.. "tables, and the row must not shift the white it sits on")
-- What has to hold is not which slot the red is in but where it comes OUT:
-- black text is shade 3 and the white box fill shade 0, and the display mode
-- transforms the table before the shader ever sees it (effectiveColors). So
-- assert through that, which is the thing the screen actually draws.
do
local eff = PaletteFX.effectiveColors(red)
T.check(eff[4][1] == 255 and eff[4][2] == 0 and eff[4][3] == 0,
"the ink shade comes out red")
T.eq(eff[1][2], 239, "and the paper shade is the white it always was")
end
-- SGB INV REVERSES the table, so the red has to start in the other slot to
-- land on the ink. Without the special case the row draws as a solid red
-- block with the letters cut out of it.
PaletteFX.mode = "gbc_inv"
do
local eff = PaletteFX.effectiveColors(Menus.lib.redPalette(Data))
T.check(eff[4][1] == 255 and eff[4][2] == 0,
"under SGB INV the ink shade STILL comes out red, once the mode has had "
.. "its turn at the table")
T.eq(eff[1][1], 0,
"on the same dark paper SGB INV gives every other row -- red ink, not a "
.. "red block")
end
PaletteFX.mode = "gbc"
-- the band covers the two TEXT lines of the box and nothing else: tile 1 is
-- the cursor and tiles 0 and 19 are the box borders, all of them black glyphs
-- that would turn red with the label if the zone spanned the whole row
local zone = Menus.lib.rowZone(Data, 1)
T.eq(zone.x, 16, "the band starts at the label column, past the cursor")
T.eq(zone.y, 8, "on the first box's label line")
T.eq(zone.w, 17 * 8, "and stops short of the box's right border")
T.eq(zone.h, 16, "covering the label and the value under it")
T.eq(Menus.lib.rowZone(Data, 3).y, 8 * ((3 - 1) * 4 + 1),
"and it is addressed by SLOT, so it follows the row down the screen")
-- on the menu itself: the whole-screen palette it always had, plus one band
menuGame.stack.states = {}
Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
Pipelines.rows(menuGame))
local palMenu = OptionsMenu.new(menuGame)
T.eq(#palMenu:sgbPalettes(menuGame), 2,
"the OPTIONS menu paints its own palette and then the one band")
T.eq(palMenu:sgbPalettes(menuGame)[2].y, 8,
"on row 1, where the mod's row leads the list")
T.eq(palMenu:sgbPalettes(menuGame)[2].colors[4][1], 255, "and it is the red one")
-- scrolled past, the band goes with it: a red stripe left behind on whatever
-- took the slot would be worse than no marker at all
palMenu.scroll = 4
T.eq(#palMenu:sgbPalettes(menuGame), 1,
"scrolled off screen, the row takes its band with it")
-- and a submenu paints its own. This is the whole reason it has the method:
-- Game:draw stops at the first state that HAS one, so a submenu without it
-- would inherit the band above and land it on an unrelated row.
T.eq(#Menus.lib.new(menuGame, Menus.lib.ROOT):sgbPalettes(menuGame), 1,
"a submenu owns its palette rather than inheriting the red band")
Data.palettes = nil
end
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu
Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
do
-- what the engine really hands the hook: its own rows, the pipeline rows
-- among them. The mod moves those two onto its root menu, so a call that
-- left them out would leave the root menu with nothing but categories.
local given = { { id = "text_speed" } }
for _, row in ipairs(Pipelines.rows({ data = Data })) do
given[#given + 1] = row
end
Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, given)
end
-- Every row the mod offers, keyed by id. RENDER DIST, FOREST FX, LET'S GO,
-- SHADOWS and AA are read straight out of it where they are used rather than
-- named here, because this chunk is one main function and has 200 local slots
-- to spend.
local hookedRows = Menus.offered()
-- one per setting, plus the STADIUM ROM action row -- which is not a setting
-- (nothing to store, nothing for the mod manager to persist) and is offered
-- on every platform, saying WHERE? rather than IMPORT where there is no file
-- dialog to open
T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the "
.. "STADIUM ROM action row")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
-- hookedRows[9] and AA hookedRows[11]; all four are read where they are
-- used rather than named here, because this chunk is one main function and
-- has 200 local slots to spend
T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
T.eq(hookedRows[4].value(), "FIT",
-- dialog to open -- and the four category rows that carry them
do
-- named rather than counted: a count says "something moved" and this says
-- WHAT is meant to be reachable. SMOOTH TURN is absent because VR is off,
-- which is its own gate and is tested where VR is turned on.
local want = { "grid", "curve", "viewbox", "water", "daytime",
"battles", "battleBack", "letsgo", "stadiumRom",
"atmos", "shadows", "aa", "vr" }
local n = 0
for _ in pairs(hookedRows) do n = n + 1 end
for _, key in ipairs(want) do
T.check(hookedRows["DRAMATIC_SHAPE:" .. key],
("%s is reachable from the mod's menus"):format(key))
end
T.check(hookedRows["pipeline:voxel"] and hookedRows["pipeline:tiltshift"],
"and both pipeline rows with them")
-- those, the two pipelines and the four categories, and nothing else
T.eq(n, #want + 2 + 4, "and nothing else is on them")
end
local grid = hookedRows["DRAMATIC_SHAPE:grid"]
local curve, water = hookedRows["DRAMATIC_SHAPE:curve"], hookedRows["DRAMATIC_SHAPE:water"]
local battles = hookedRows["DRAMATIC_SHAPE:battles"]
local backRow, daytime = hookedRows["DRAMATIC_SHAPE:battleBack"],
hookedRows["DRAMATIC_SHAPE:daytime"]
T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].label, "RENDER DIST",
"the viewport row carries its label")
T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].value(), "FIT",
"and defaults to FIT -- the cut IS the window the flat game already "
.. "framed, which is the whole claim the row makes")
T.eq(water.label, "WATER", "the water row carries its label")
@@ -424,8 +894,8 @@ T.eq(water.value(), "FULL",
water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
T.eq(water.value(), "SKY",
"stepping down drops the screen-space march and keeps the sky, sun and moon")
T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label")
T.eq(hookedRows[6].value(), "FULL",
T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].label, "FOREST FX", "the atmosphere row carries its label")
T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].value(), "FULL",
"and defaults to FULL -- it only spends anything on a map with an "
.. "atmosphere entry, which is one forest today")
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
@@ -446,8 +916,8 @@ T.eq(backRow.value(), "OFF",
.. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
"on its own key, so it persists beside 3D-BTL rather than over it")
T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label")
T.eq(hookedRows[9].value(), "OFF",
T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].label, "LET'S GO", "the capture-mode row carries its label")
T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].value(), "OFF",
"and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
-- stepping writes through to the one place both rows read
@@ -527,7 +997,7 @@ do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
local aa = hookedRows[11]
local aa = hookedRows["DRAMATIC_SHAPE:aa"]
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -567,6 +1037,49 @@ AntiAlias.expand(320, 200)
T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass")
end
-- ------- SHADOWS: the sun's own pass, and the row that stands it down
--
-- The most expensive thing in the frame after the geometry -- the whole world
-- drawn a second time from the light, every time anything in it moves -- so
-- the row exists for the machine that cannot carry it. What it must do is
-- switch shadows OFF rather than down: both halves of ShadowMap (can the pass
-- run, and is there a map to read) hang off the one gate, because the map
-- from the last frame is still in the canvas and a row that only stopped the
-- pass would leave every shadow frozen in the pose it was switched off in.
--
-- In an anonymous scope, like every section that wants more than a name or
-- two: this file is one chunk and a chunk has 200 local slots, which the
-- main function is already sitting on.
;(function()
local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows")
local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
local shadowRow = hookedRows["DRAMATIC_SHAPE:shadows"]
local shGame = { save = { options = {} }, mods = { modOptions = {} } }
T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label")
T.eq(shadowRow.value(), "ON",
"and starts ON -- cast shadows are as much of the mode as the geometry, "
.. "and the row is for the machine that cannot have them")
T.eq(Shadows.enabled(), true, "which is what the pass reads")
T.eq(ShadowMap.wanted(), true, "and the gate both halves of the sun pass ask")
shadowRow.step(shGame, 1)
T.eq(shadowRow.value(), "OFF", "stepping the row stands the sun pass down")
T.eq(shGame.save.options.modOptions.DRAMATIC_SHAPE.shadows, false,
"the choice persists beside the other settings, not over them")
T.eq(Shadows.enabled(), false, "the pass sees it")
T.eq(ShadowMap.wanted(), false, "through the one gate")
T.eq(ShadowMap.available(), false,
"so the sun pass never opens -- the frame's second draw of the world is "
.. "the whole thing being saved")
T.eq(ShadowMap.active(), false,
"and nothing reads the map either: a pass stopped WITHOUT this would leave "
.. "the last frame's shadows standing forever")
shadowRow.step(shGame, 1)
T.eq(shadowRow.value(), "ON", "and the row is a toggle -- one step back on")
T.eq(ShadowMap.wanted(), true, "with the sun asked for again")
end)()
-- ------- the animated terrain atlas survives an engine without its seams
--
-- Regression: cycling palette modes with voxel mode on eventually killed
@@ -3288,19 +3801,16 @@ T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
-- ...so the row comes off the menu with it, on the same reasoning the mod's
-- other absent rows come off: a row that no longer decides anything is worse
-- than no row
local offRows = Runtime.call("ui.options.rows", function(_, r) return r end,
backGame, { { id = "tilt" } })
local offIds = {}
for _, row in ipairs(offRows) do offIds[row.id] = true end
T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
T.check(not offIds["DRAMATIC_SHAPE:battleBack"],
local offRows = Menus.rows("battles", backGame)
T.check(offRows["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
T.check(not offRows["DRAMATIC_SHAPE:battleBack"],
"but BACK SPRITES is off the menu while there is no staged fight to be about")
Battles.setting:setValue(true, backGame)
local onRows = Runtime.call("ui.options.rows", function(_, r) return r end,
backGame, { { id = "tilt" } })
-- switched back on it returns, and to the BATTLES menu -- the one the switch
-- it depends on is on, so the two are read together
local onAt = {}
for i, row in ipairs(onRows) do onAt[row.id] = i end
for i, row in ipairs(Menus.lib.rows("battles", backGame)) do onAt[row.id] = i end
T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
"directly under the row it belongs to")
@@ -5914,26 +6424,28 @@ end)()
local VRMod = lib.require("VR")
T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box")
local function optionRows()
local out = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "tilt" } })
local ids = {}
for _, row in ipairs(out) do ids[row.id] = row end
return ids
end
Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows
VRMod.setting:sync(false)
T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"],
"with VR off the row is not on the OPTIONS menu")
T.check(not Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"],
"with VR off the row is not on the VR menu")
-- and with the headset's own row the only thing left on it, that menu is
-- still worth offering: a category is dropped when it is EMPTY, not when it
-- is short
T.check(Menus.rows(Menus.lib.ROOT)[Menus.catId("vr")],
"though the category is, on the strength of the VR row alone")
VRMod.setting:sync(true)
local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"]
local smoothRow = Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"]
T.check(smoothRow ~= nil, "and with VR on it is")
if smoothRow then
T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
end
-- VR on takes the two battle rows away, which is the other half of the same
-- gate: the headset requires staged battles and forbids back sprites, so
-- both rows decide nothing there
T.check(not Menus.rows("battles")["DRAMATIC_SHAPE:battles"],
"and the battle rows are gone, being decided by the headset instead")
VRMod.setting:sync(false)
-- ------- the gun's own bookkeeping
@@ -6187,6 +6699,10 @@ T.check(box.z < 200,
.. "the view centre is the bug that cut the top off")
do
local n, s, x = VB.footprint(math.rad(35), 320, 288, REACH)
-- the south edge is the ground the bottom ray lands on PLUS what stands
-- there: a tree's feet are south of the row its top is seen on, and the
-- cut is by column (see lift)
s = s + VB.lift(math.rad(35)) + VB.SOUTH_PAD
T.check(math.abs(box.rz - (n + s) * 0.5) < 1e-6,
"the half-depth spans the footprint from its south edge to its north")
T.check(math.abs(box.z - (200 - (n - s) * 0.5)) < 1e-6,
@@ -6196,6 +6712,49 @@ do
"both bigger than the window at every tilted rung, so the cut can "
.. "never take a bite out of the picture")
end
-- ------- and the geometry standing on the ground it frames
--
-- The bug: the box was cut to where the frame's rays hit the GROUND, and
-- the ground is not what the picture is made of. At every rung past the
-- frame's own half-angle the bottom of the screen shows things whose FEET
-- are south of that line, and the shader cuts by column -- so a tree one
-- pixel outside lost its whole height and the bottom of the frame wore a
-- bite out of the scenery, the ground behind showing through.
do
local tanY = 1 / (2 * VS.FOCAL)
T.eq(VB.lift(0), 0,
"straight down nothing is lost: a raised point lands NORTH of where its "
.. "own ray hits the ground, which no cut can take")
T.eq(VB.lift(math.atan(tanY)), 0,
"and up to atan(tanY) -- the frame's own half-angle -- it still is")
T.check(VB.lift(math.rad(35)) > 0 and VB.lift(math.rad(50)) > VB.lift(math.rad(35))
and VB.lift(math.rad(75)) > VB.lift(math.rad(50)),
"past it the lift opens with the tilt: the flatter the eye, the more of "
.. "a tree is what sits under the bottom of the frame")
-- against the ray it is derived from: a point HEIGHT up on the bottom
-- edge's own ray, dropped to the ground, is exactly this far south of
-- where that ray lands
for _, deg in ipairs({ 35, 50, 75 }) do
local a = math.rad(deg)
local dy = -(math.cos(a) + tanY * math.sin(a))
local dz = -(math.sin(a) - tanY * math.cos(a))
T.check(math.abs(VB.lift(a) - VB.HEIGHT * (-dz / -dy)) < 1e-9,
("%d degrees: the lift is that ray walked back up by HEIGHT")
:format(deg))
end
-- and it is in the CUT, at FIT, which is the rung the artefact was seen on
VS.angle = math.rad(35)
local fit = VB.frame(0, 0, 320, 288, 3)
local n, s = VB.footprint(math.rad(35), 320, 288, REACH)
T.check(fit.z + fit.rz >= s + VB.lift(math.rad(35)) - 1e-6,
"so the box's south edge clears the feet of everything the bottom of "
.. "the frame can show, tree tops included")
T.check(fit.z + fit.rz > s and fit.z - fit.rz < -n + 1e-6,
"without moving the north edge, which the reach already decides")
end
VS.angle = math.rad(35)
box = VB.frame(100, 200, 320, 288, 3)
T.check(box.rx ~= box.rz,
"which is the whole reason the box kind carries two half-extents: a "
.. "square cut on a wide frame either loses the sides or overshoots")
@@ -6305,6 +6864,44 @@ end)()
mon = { level = 100 } }, 1) >= 1,
"a trivial catch still pays at least one point")
-- ------- the capture seat speaks the pull's own language
--
-- The seat hands BattleScene a pitch, and the only thing downstream reads
-- it is the camera-ward pull the grass and flowers are drawn with. That
-- angle is measured off STRAIGHT DOWN -- Voxel.angle's convention, and
-- BattleCam.rig's -- and this rig used to answer the DEPRESSION below
-- level instead, which is its complement. A seat looking nearly level
-- therefore read as "straight down", the end of the ladder where the pull
-- is at its longest: 46 world pixels of bias handed to a camera standing
-- 46 world pixels behind the player. The pull is a shove along each
-- vertex's own eye ray, so at that range it carried the grass at the eye
-- THROUGH the lens, and a tuft landed smeared across the top of the frame.
do
local CatchThrow = lib.require("CatchThrow")
local BattleCam = lib.require("BattleCam")
local VoxelScene = lib.require("VoxelScene")
-- two mons two cells apart, the arena laid down unturned
local arena = { player = { 100, 200 }, enemy = { 100, 168 },
mid = { 100, 184 }, turn = 0 }
local seat, pitch = CatchThrow._rig(arena, 0)
T.check(pitch > math.rad(75),
("the capture seat reads as NEARLY LEVEL (%.1f degrees off straight "
.. "down), which is what it is"):format(math.deg(pitch)))
local _, camPitch = BattleCam.rig(arena, 0, true)
T.check(camPitch > math.rad(45) and pitch > math.rad(45),
"in the same convention the battle's own rig hands back, so one pull "
.. "formula can serve both seats")
-- the invariant the bug broke: a bias along the eye ray must never
-- reach the eye, or the vertex comes out behind the lens
local dx, dz = seat.eye[1] - arena.player[1], seat.eye[3] - arena.player[2]
local range = math.sqrt(dx * dx + dz * dz)
T.check(VoxelScene.pull(math.max(pitch, 0.05)) < range * 0.5,
("the grass pull (%.1f px) stays well inside the seat's own range to "
.. "the player's cell (%.1f px) -- the tufts it is biasing are the "
.. "ones standing right there")
:format(VoxelScene.pull(math.max(pitch, 0.05)), range))
end
-- and the same formula pays TRAINER knockouts under FULL, with the
-- wild/trainer 1.5 that a catch never sees (a caught Pokemon is always
-- wild, which is why the catch numbers above are untouched by it)
@@ -6337,6 +6934,49 @@ end)()
-- the row answers the mode, and OFF answers false
T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF")
-- The capture's buttons are read on the LOGIC STEP, through the engine's
-- input.step seam, because edges do not survive the render clock (see
-- CatchThrow.buttons). The hook calls it under pcall, so a rename would
-- be swallowed silently and B would simply stop working -- which is
-- exactly the failure it was written to fix. Assert the name exists, and
-- that it is harmless with no session, since it runs every single step.
do
local CatchThrow = lib.require("CatchThrow")
T.eq(type(CatchThrow.buttons), "function",
"the capture reads its buttons on the logic step, by name")
local q = { "a", "b" }
CatchThrow.buttons({ input = { pressQueue = q } })
T.eq(#q, 2, "and with no session in flight it takes nothing and does nothing")
end
-- ------- the scripted catch tutorials are none of LET'S GO's business
--
-- The VIRIDIAN CITY old man and Yellow's PROF.OAK / PIKACHU intro are
-- the same makeOldManDemo cutscene: the cursor, the bag and the throw
-- are all scripted, and nobody keeps the Pokemon. Every rung has to
-- leave them exactly as the engine plays them.
T.eq(LetsGo.scripted({ demo = true }), true,
"the old man's tutorial is a scripted battle")
T.eq(LetsGo.scripted({ demo = true, oakDemo = true }), true,
"and so is PROF.OAK catching the PIKACHU")
T.eq(LetsGo.scripted({ kind = "wild" }), false,
"an ordinary wild encounter is not")
T.eq(LetsGo.scripted(nil), false, "and no battle at all is not either")
do
-- the predicates that gate every FULL behaviour, read at FULL
local realMode = LetsGo.mode
LetsGo.mode = function() return "full" end
T.eq(LetsGo.fullWild({ kind = "wild" }), true,
"FULL owns an ordinary wild encounter")
T.eq(LetsGo.fullWild({ kind = "wild", demo = true }), false,
"but never the old man's demo, whatever the row says")
T.eq(LetsGo.fullWild({ kind = "wild", oakDemo = true }), false,
"nor PROF.OAK's")
T.eq(LetsGo.wantsMinigame({ kind = "wild", demo = true }), false,
"and a scripted throw is never handed the minigame")
LetsGo.mode = realMode
end
-- the ball: a full open-drop-rock-click life without a GPU
T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on")
do
+166
View File
@@ -0,0 +1,166 @@
-- Probe: does WASD still walk CAMERA-RELATIVE on the 1ST/3RD rungs?
--
-- The regression report is "in first and third person the wasd keys now
-- move in cardinal directions". Cardinal means the yaw is not being
-- applied -- either FreeMove.tick is not the handler that ran (so the
-- engine's grid walk did, which is cardinal by construction), or it ran
-- and moveWorld got a yaw of zero.
--
-- So measure both: which handler took the frame, what the yaw was, and
-- which way the player actually travelled for a held W.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/freemove_probe.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
U.log("DRAMATIC_SHAPE is not loaded")
return love.event.quit()
end
local V = handle.lib
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local Voxel = V.require("VoxelState")
local ChunkMesher = V.require("ChunkMesher")
require("src.world.OverworldController").rollEncounter = function() return nil end
-- FirstPerson captures the mouse only with WINDOW FOCUS, and a driver
-- window never has it -- so without this the look reads as dead for a
-- reason that has nothing to do with the code under test. Force the
-- answer it gates on; setRelativeMode then arms and the relative-motion
-- wrap claims the deltas exactly as it would for a player.
love.window.hasFocus = function() return true end
-- count which walk handler actually takes the frames
local ticks = 0
local innerTick = FreeMove.tick
FreeMove.tick = function(...)
ticks = ticks + 1
return innerTick(...)
end
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(60)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if FirstPerson.blend >= 1 and Voxel.ready then break end
U.wait(1)
end
U.wait(30)
end
-- W is the UP button; the B button's key is "x" (see Input's
-- DEFAULT_BINDINGS -- the driver must press KEYS, not button names, to
-- exercise the real path)
local function holdKey(k, frames)
love.keypressed(k, k, false)
U.wait(frames)
love.keyreleased(k, k)
U.wait(4)
end
for _, rung in ipairs({ { "1ST", Voxel.FP_LEVEL }, { "3RD", Voxel.TP_LEVEL } }) do
Pipelines.setLevel("voxel", rung[2])
settle()
-- face EAST: yaw is the free-roam look, and a camera-relative W must
-- then walk +X. A cardinal W walks -Y (north) whatever the camera does.
FirstPerson.yaw = math.pi / 2
U.wait(10)
local ow = game.stack:top()
local p = ow and ow.player
if not p then U.log(rung[1] .. ": no player") break end
local x0, y0 = p.px, p.py
ticks = 0
holdKey("w", 40)
local dx, dy = p.px - x0, p.py - y0
U.log(("%s: driving=%s freeMove ticks=%d yaw=%.2f W moved dx=%.1f dy=%.1f")
:format(rung[1], tostring(FirstPerson.driving()), ticks,
FirstPerson.yaw, dx, dy))
local wx, wz = FirstPerson.moveWorld(0, 1)
U.log(("%s: moveWorld(0,1) = %.2f,%.2f (want a mostly-X vector at this yaw)")
:format(rung[1], wx, wz))
-- ------- and does the LOOK still turn?
--
-- A yaw that never moves is the same symptom from the player's seat:
-- it stays at the cardinal angle the rung was entered on (FACING_ANGLE
-- is one of four compass points), so W walks due north for ever and
-- "wasd moves in cardinal directions" is exactly what it feels like.
-- The capture mode's pointer wraps are installed OUTSIDE FirstPerson's,
-- so this is the path that could have regressed.
-- FirstPerson only claims relative motion while it has CAPTURED the
-- mouse, and it captures only with window focus. A driver window that
-- never got focus would show a dead look for a reason that has nothing
-- to do with the code -- so record the discriminator rather than read
-- a zero and blame the wrap.
local okF, focus = pcall(function() return love.window.hasFocus() end)
local okR, rel = pcall(function() return love.mouse.getRelativeMode() end)
U.log(("%s: engaged=%s focus=%s relativeMode=%s")
:format(rung[1], tostring(FirstPerson.engaged()),
okF and tostring(focus) or "?",
okR and tostring(rel) or "?"))
local before = FirstPerson.yaw
for _ = 1, 10 do
love.mousemoved(400, 300, 12, 0, false)
U.wait(1)
end
U.wait(4)
U.log(("%s: mouse look -- yaw %.3f -> %.3f (delta %.3f)%s")
:format(rung[1], before, FirstPerson.yaw, FirstPerson.yaw - before,
math.abs(FirstPerson.yaw - before) < 1e-6
and " <-- THE LOOK IS DEAD" or ""))
end
-- ------- and now the way a PLAYER gets there: the "3" hotkey
--
-- Pipelines.setLevel above is the driver's shortcut. A player cycles the
-- rung with 3, which goes through the mod's own cycleVoxel. If that
-- leaves Voxel.level disagreeing with the pipeline's level, the camera
-- can be first-person while FreeMove.engaged() says no -- and then the
-- ENGINE's grid handler takes the frame, which walks cardinally. That is
-- the reported symptom exactly, so it is worth entering the rung the
-- same way the report did.
Pipelines.setLevel("voxel", 0)
U.wait(20)
for i = 1, 8 do
love.keypressed("3", "3", false)
U.wait(3)
love.keyreleased("3", "3")
U.wait(12)
local lvl = Pipelines.level("voxel")
U.log(("hotkey 3 x%d -> pipeline level=%s Voxel.level=%s freeCam=%s "
.. "engaged=%s driving=%s")
:format(i, tostring(lvl), tostring(Voxel.level),
tostring(Voxel.isFreeCam(Voxel.level)),
tostring(FirstPerson.engaged()),
tostring(FirstPerson.driving())))
if Voxel.isFreeCam(Voxel.level) then
settle()
local ow = game.stack:top()
local p = ow and ow.player
FirstPerson.yaw = math.pi / 2
U.wait(6)
local yaw0 = FirstPerson.yaw
local x0, y0 = p.px, p.py
ticks = 0
holdKey("w", 30)
U.log((" walked from the HOTKEY rung: ticks=%d yaw=%.2f dx=%.1f dy=%.1f%s")
:format(ticks, yaw0, p.px - x0, p.py - y0,
ticks == 0 and " <-- ENGINE GRID WALK (cardinal)" or ""))
end
end
FreeMove.tick = innerTick
U.log("done")
end
+367
View File
@@ -0,0 +1,367 @@
-- Driver: the two edges the capture mode has to hold on to.
--
-- A FULL with an EMPTY BAG. The encounter must still be a Let's Go
-- encounter -- head-on seat, no player Pokemon, no classic menu --
-- with the readout saying there is nothing to throw and RUN as the
-- way out. The failure this catches is the old behaviour: falling
-- back to the battle menu, which under FULL offers a FIGHT against a
-- foe that never takes a turn.
--
-- B FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to
-- fall short: the miss must land in the empty hand rather than
-- tearing the session down.
--
-- C The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK
-- and the PIKACHU are the same makeOldManDemo). LET'S GO must not
-- touch it at any rung -- no session, no held camera, no veil -- and
-- it must play its scripted throw through to its own ending.
--
-- SHOT_DIR=.scratchpad/letsgo \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Bag = require("src.inventory.Bag")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local LetsGo = lib.require("LetsGo")
local CatchThrow = lib.require("CatchThrow")
local BattleScene = lib.require("BattleScene")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 10),
}
game.save.player.name = "RED"
LetsGo.setting:setValue("full", game)
if os.getenv("DS_RUNG") == "cards" then
lib.require("OverworldBattle").setting:setValue(true, game)
U.log("3D-BTL forced to 2D-3D A")
end
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
-- every ball out of the bag, whatever the save arrived with
local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
local function emptyBag()
for _, id in ipairs(BALLS) do
local n = game.save.inventory[id] or 0
if n > 0 then pcall(Bag.remove, game.save, id, n) end
game.save.inventory[id] = nil
end
end
local function ballCount()
local n = 0
for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end
return n
end
-- A REAL key event, not U.tap's synthetic pressQueue inject. The bug
-- this driver has to be able to see (button edges read on the render
-- clock instead of the logic step) lives between love.keypressed and
-- whoever polls the edge, so a driver that writes the queue itself
-- jumps straight over it.
--
-- The B BUTTON's keyboard binding is "x" (or backspace) -- Input's
-- DEFAULT_BINDINGS. The `b = "b"` next to it is the GAMEPAD table, so a
-- driver that presses the "b" KEY presses nothing at all and reports a
-- dead button whatever the code does.
local B_KEY = "x"
local function key(name)
love.keypressed(name, name, false)
U.wait(2)
love.keyreleased(name, name)
U.wait(2)
end
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(90)
-- drive the intro chatter until the menu would open (which under FULL is
-- when capture mode takes over instead)
local function toCapture(battle)
U.wait(70)
for _ = 1, 80 do
if battle.phase == "menu" or CatchThrow.session() then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(30)
end
-- Tap the fight all the way off the stack before the next case starts.
-- Cases that merely tapped A a fixed number of times left the previous
-- battle (and its session) alive whenever an outcome ran long, and the
-- next case then measured the leftover -- which reads as that case
-- failing, at a spot nowhere near the cause.
local function closeOut(battle)
for _ = 1, 400 do
if not CatchThrow.session() and game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(4)
end
U.wait(40)
end
local function describe(tag, battle)
local s = CatchThrow.session()
U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
:format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-",
s and s.phase or "-", tostring(battle.phase),
BattleScene.capture and "up" or "down",
tostring(BattleScene.capture
and BattleScene.capture.hidePlayer)))
return s
end
-- ------- A: an empty bag still gets the capture screen
emptyBag()
U.log("== A: FULL with " .. ballCount() .. " balls")
local a = BattleState.newWild(game, "PIDGEY", 5)
a.onFinish = function() end
game.overworld:pushBattle(a)
toCapture(a)
local s = describe("A", a)
U.log(("A: hand is empty -> %s (want yes)"):format(
(s and s.empty) and "yes" or "NO"))
U.shot(game, DIR .. "/empty_1_aim.png")
-- and B is the way out: a Let's Go wild always escapes
key(B_KEY)
for _ = 1, 120 do
U.wait(2)
if a.result then break end
U.tap(game, "a")
end
U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result)))
-- the session and the held camera are swept by battle.ended, which is the
-- teardown BELOW this, not the moment `result` is written -- so the sweep
-- is only worth asserting once the battle has actually left the stack
closeOut(a)
U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
:format(CatchThrow.session() and "still up" or "gone",
BattleScene.capture and "still up" or "down"))
-- ------- B: the LAST ball, thrown short
Bag.add(game.save, "POKE_BALL", 1, game.data)
CatchThrow.lastBall = "POKE_BALL"
U.log("== B: FULL with " .. ballCount() .. " ball")
local b = BattleState.newWild(game, "PIDGEY", 5)
b.onFinish = function() end
-- The outcome under test is what happens when the LAST ball MISSES, so
-- the roll must not be left to chance: a run where the Pidgey happened
-- to be caught used to cascade into every later case (the session lived
-- on into its epilogue, and C and D then measured that leftover instead
-- of their own). Forced failure, three shakes -- the roll is the
-- engine's business and is covered elsewhere.
b.catchAttempt = function() return false, 3 end
game.overworld:pushBattle(b)
toCapture(b)
describe("B", b)
-- a deliberately feeble swipe: it must fall short of the Pokemon, so
-- the miss is the outcome under test rather than a lucky catch
local aim = CatchThrow._aimInfo()
if aim then
local uw, uh = love.graphics.getDimensions()
local function toWin(gx, gy)
return (aim.lx + gx * aim.scale) * uw / aim.pw,
(aim.ly + gy * aim.scale) * uh / aim.ph
end
local hx, hy = aim.hand[1], aim.hand[2]
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
local d = math.sqrt(dx * dx + dy * dy)
dx, dy = dx / d, dy / d
local step = 120 / 60
local px, py = toWin(hx, hy)
love.mousepressed(px, py, 1, false, 1)
for i = 1, 8 do
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
love.mousemoved(wx, wy, 0, 0, false)
U.wait(1)
end
local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8)
love.mousereleased(wx, wy, 1, false, 1)
else
U.log("B: NO AIM INFO -- capture mode did not open")
end
-- ride the throw out, then keep tapping through the miss text until the
-- hand is refilled -- which, with the bag now empty, means the EMPTY hand
for _ = 1, 400 do
U.wait(2)
local q = CatchThrow.session()
if q and q.phase == "aim" and q.empty then break end
if not q then break end
if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end
end
local sb = describe("B", b)
U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)")
:format(ballCount(),
(sb and sb.empty) and "empty hand" or
(sb and ("still holding " .. tostring(sb.ballId)))
or "SESSION GONE"))
U.shot(game, DIR .. "/empty_2_ranout.png")
key(B_KEY)
for _ = 1, 120 do
U.wait(2)
if b.result then break end
U.tap(game, "a")
end
U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
closeOut(b)
-- ------- C: the scripted tutorial, untouched
Bag.add(game.save, "POKE_BALL", 10, game.data)
U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls")
local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
local c = BattleState.newWild(game, om.species, om.level)
c:makeOldManDemo()
c.onFinish = function() end
U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c))
.. " fullWild -> " .. tostring(LetsGo.fullWild(c))
.. " wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c)))
game.overworld:pushBattle(c)
-- the demo drives ITSELF: the cursor, the bag and the throw are all
-- scripted, so this only watches. Any session or veil appearing here is
-- the failure.
local sawSession, sawVeil = false, false
local shotDemo = false
for i = 1, 900 do
if CatchThrow.session() then sawSession = true end
if BattleScene.capture then sawVeil = true end
if not shotDemo and i > 200 then
shotDemo = true
U.shot(game, DIR .. "/empty_3_oldman.png")
end
if c.result then break end
U.wait(2)
-- the scripted beats want A only to page the text along
if i % 3 == 0 then U.tap(game, "a") end
end
U.log(("C: session ever opened=%s (want no) veil ever up=%s (want no)")
:format(tostring(sawSession), tostring(sawVeil)))
U.log(("C: result=%s balls=%d (want 10 -- the demo consumes none)")
:format(tostring(c.result), ballCount()))
U.log(("C: party still %d, first is %s")
:format(#game.save.party,
game.data.pokemon[game.save.party[1].species].name))
-- ------- D: B RUNS, with a ball in hand, on a frame that runs several
-- logic steps
--
-- The reported failure, reproduced rather than reasoned about. Input:step
-- rebuilds the edge table once per FIXED step; Game:update runs the
-- frame's steps first and the render-clock hooks after, so any frame
-- carrying more than one step has already discarded the earlier steps'
-- edges. Below 60fps -- which is where a 3D battle lives -- that is
-- every press. speedOverride multiplies the logic clock, so it packs
-- several steps into each frame on demand and turns "sometimes, on a
-- slow machine" into "every time, here".
for _, speed in ipairs({ 1, 4 }) do
Bag.add(game.save, "POKE_BALL", 5, game.data)
CatchThrow.lastBall = "POKE_BALL"
game.speedOverride = speed > 1 and speed or nil
U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed))
local d = BattleState.newWild(game, "PIDGEY", 5)
d.onFinish = function() end
game.overworld:pushBattle(d)
toCapture(d)
local sd = describe("D" .. speed, d)
if sd and not sd.empty then
-- where does the key actually get to? Each stage of the chain, so a
-- dead B is attributed rather than guessed at
local top = game.stack and game.stack:top()
U.log(("D%dX probe: top=%s onKeyPressed=%s fullWild=%s declinable=%s")
:format(speed, tostring(top and top.screenId or "?"),
tostring(top and top.onKeyPressed ~= nil),
tostring(sd.fullWild), tostring(sd.declinable)))
-- ------- the counter-factual, measured rather than argued
--
-- How many times an edge would have been visible to a poll on the
-- RENDER clock -- where these reads used to live. Measured on UP
-- rather than B: the fix TAKES B out of the queue, so B never
-- reaches `pressed` any more and would read as a false zero. UP is
-- untouched by everything while the battle is parked, and the
-- question is about step-vs-frame ordering, not about which button.
local onFrame, onStep = 0, 0
local innerU = CatchThrow.update
CatchThrow.update = function(dt)
if game.input.wasPressed and game.input:wasPressed("up") then
onFrame = onFrame + 1
end
return innerU(dt)
end
local innerB0 = CatchThrow.buttons
CatchThrow.buttons = function(g)
local q = g and g.input and g.input.pressQueue
if q then
for i = 1, #q do if q[i] == "up" then onStep = onStep + 1 end end
end
return innerB0(g)
end
love.keypressed("up", "up", false)
U.wait(2)
love.keyreleased("up", "up")
U.wait(2)
CatchThrow.buttons = innerB0
CatchThrow.update = innerU
U.log(("D%dX counter-factual: one UP press -- the logic step saw it "
.. "%d time(s), the RENDER clock %d time(s)%s")
:format(speed, onStep, onFrame,
onFrame == 0 and " <-- a frame poll misses it entirely"
or ""))
local seen = 0
local innerB = CatchThrow.buttons
CatchThrow.buttons = function(g)
local q = g and g.input and g.input.pressQueue
if q and #q > 0 then
seen = seen + 1
U.log("D probe: buttons saw queue [" .. table.concat(q, ",") .. "]")
end
return innerB(g)
end
love.keypressed(B_KEY, B_KEY, false)
U.log(("D probe: right after keypressed -- queue=[%s] state.b=%s")
:format(table.concat(game.input.pressQueue, ","),
tostring(game.input.state.b)))
U.wait(2)
love.keyreleased(B_KEY, B_KEY)
U.wait(2)
CatchThrow.buttons = innerB
U.log("D probe: buttons saw a non-empty queue " .. seen .. " time(s)")
local ran = false
for _ = 1, 60 do
U.wait(2)
if d.result then ran = true break end
end
U.log(("D%dX: after a REAL B -- result=%s (want run) %s")
:format(speed, tostring(d.result),
ran and "RAN" or "*** B DID NOTHING ***"))
else
U.log("D" .. speed .. ": no armed session to test")
end
-- escape hatch: a B that did nothing leaves the battle parked in the
-- capture phase forever, and the rest of the run must still report
if not d.result then
pcall(CatchThrow.onBattleEnded)
d.result, d.phase, d.afterQueue = "run", "messages", "finish"
end
closeOut(d)
game.speedOverride = nil
end
U.log("done -- " .. DIR)
end
+104
View File
@@ -0,0 +1,104 @@
-- Probe: the reported grass along the TOP of the frame in GO-style
-- battles on ROUTE 6.
--
-- The capture seat is a different camera from the battle's own: the "tele"
-- rig stands 145 world px back at a height of 37.9 (well over two cells,
-- clear of anything that grows on the ground), while the head-on capture
-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass
-- tuft or hedge. On a route lined with the stuff, the eye is inside it.
--
-- So: stage a capture at several spots along Route 6 and record where the
-- eye actually is relative to the ground, with a shot of each.
--
-- SHOT_DIR=.scratchpad/route6 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Bag = require("src.inventory.Bag")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded")
return
end
local LetsGo = lib.require("LetsGo")
local CatchThrow = lib.require("CatchThrow")
local ChunkMesher = lib.require("ChunkMesher")
pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul')
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
game.save.player.name = "RED"
Bag.add(game.save, "POKE_BALL", 99, game.data)
CatchThrow.lastBall = "POKE_BALL"
LetsGo.setting:setValue("full", game)
local MAP = os.getenv("DS_MAP") or "ROUTE_6"
-- a spread down the route: the reported shot is on the path with hedges
-- both sides, which is where a low seat has the least room
local SPOTS = {}
for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do
SPOTS[#SPOTS + 1] = xy
end
for i, sp in ipairs(SPOTS) do
U.teleport(game, MAP, sp[1], sp[2], "up")
U.wait(50)
-- confirm the teleport actually landed: a first cut reported the SAME
-- eye at all five spots, which meant the probe never left the save's
-- own position and every "spot" was one place wearing five labels
do
local ow = game.overworld
local m = ow and ow.map
U.log(("spot %d: on map %s at cell %s,%s")
:format(i, tostring(m and (m.id or m.name) or "?"),
tostring(ow and ow.player and ow.player.cellX),
tostring(ow and ow.player and ow.player.cellY)))
end
for _ = 1, 600 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
local bt = BattleState.newWild(game, "ODDISH", 13)
bt.onFinish = function() end
game.overworld:pushBattle(bt)
U.wait(70)
for _ = 1, 80 do
if bt.phase == "menu" or CatchThrow.session() then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(40)
local s = CatchThrow.session()
if s and s.shot and s.shot.eye then
local e = s.shot.eye
local p = s.playerPos
local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2)
U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f height %.1f over ground "
.. "seat %.1f back (wants 46)%s")
:format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back,
back < 45.5 and " <-- the world pulled it in" or ""))
else
U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2]))
end
U.shot(game, ("%s/spot%d.png"):format(DIR, i))
-- out, and all the way off the stack before the next spot
U.tap(game, "b")
for _ = 1, 300 do
if not CatchThrow.session() and game.stack:top() ~= bt then break end
U.tap(game, "a")
U.wait(4)
end
U.wait(30)
end
U.log("done -- " .. DIR)
end
+114
View File
@@ -0,0 +1,114 @@
-- Driver: five shiny encounters, five outdoor places, paced for a capture.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_obs.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- Run from the PROJECT ROOT. Nothing here is a screenshot test -- it is a
-- performance, timed for somebody recording the window.
--
-- Each beat: teleport, let the ground mesh, open a wild battle, let the
-- Pokemon ARRIVE (the wipe, the send-out, the shiny sparkle), then hold
-- three full seconds on it before closing the battle and moving on. The hold
-- is deliberately silent -- no button taps during it -- so the recording is
-- of the Pokemon standing there and not of a text box being clicked through.
--
-- Every encounter is shiny, because the odds are pinned to 1 for the run and
-- the roll still happens where it always does, inside Pokemon.new.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- nothing to show")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
-- ------- timing, in frames at 60fps
local SETTLE = 110 -- after a teleport, for the neighbourhood to mesh
local ARRIVE = 70 -- the wipe, then the Pokemon landing on its tile
local HOLD = 180 -- THE THREE SECONDS
local BETWEEN = 45 -- back on the map before the next one opens
-- ------- the models
if not StadiumInstall.ready() then
U.log("building stadium models first...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
U.wait(1)
guard = guard + 1
end
end
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
OverworldBattle.setting:setValue("stadium", game)
-- party FIRST, at ordinary odds: Pokemon.new is where shininess is
-- decided, so pinning the odds before this made the player's own Pikachu
-- shiny -- and that tints the player's side, which during the intro is the
-- trainer sprite. The foe is what these runs are about.
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
Shiny.setOdds(1) -- from here on: every encounter
-- Five outdoor places, deliberately unalike -- a coast, an open route, a
-- water city, a rocky pass and a wooded shore -- so the recording is five
-- different-looking fights and not the same meadow five times. Each mon is
-- put somewhere its colour has something to sit against.
local RUNS = {
{ "CHARIZARD", 50, "ROUTE_4", 10, 5 },
{ "ELECTRODE", 40, "VIRIDIAN_CITY", 20, 20 },
{ "VAPOREON", 42, "ROUTE_25", 12, 5 },
{ "DRATINI", 30, "ROUTE_3", 10, 5 },
}
-- a beat before the first one, so a recorder that started with the window
-- is not already mid-encounter by the time it is rolling
U.log("starting in 3...")
U.wait(60)
U.log("2...")
U.wait(60)
U.log("1...")
U.wait(60)
for i, r in ipairs(RUNS) do
local species, level, map, cx, cy = r[1], r[2], r[3], r[4], r[5]
if not game.data.pokemon[species] then
U.log(("skip %s -- not in this dataset"):format(species))
elseif not game.data.maps[map] then
U.log(("skip %s -- no map %s"):format(species, map))
else
U.teleport(game, map, cx, cy, "down")
U.wait(SETTLE)
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%d/4 %-10s %-14s shiny=%s")
:format(i, species, map, tostring(Shiny.isShiny(mon))))
-- the arrival, then three seconds of nothing but the Pokemon
U.wait(ARRIVE)
U.wait(HOLD)
-- close the battle and go back to the map
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(BETWEEN)
end
end
Shiny.setOdds(8192)
U.log("done -- five encounters")
end
+182
View File
@@ -0,0 +1,182 @@
-- Driver: ONE shiny encounter, at real 1x speed, held open until you close
-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh.
--
-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- ------- why the pacing is done here
--
-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns
-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the
-- machine will go and the loop takes one logic step (Game:update(1/60)) per
-- turn of it. That is right for a screenshot script and wrong for a capture:
-- the game runs at whatever multiple of real time the hardware manages, so
-- "wait 180 frames" is three seconds only by coincidence.
--
-- The fix does not need an engine change. The loop is blocked while this
-- coroutine is running, so sleeping HERE before each yield paces the whole
-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by
-- construction. Nothing else in the engine has to know.
--
-- ------- and why it never finishes
--
-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So
-- holding the battle open forever is exactly how the window stays up until
-- somebody closes it, which is the handshake this run wants: one window, one
-- Pokemon, closed by hand, and only then the next.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local CX = tonumber(os.getenv("DS_CX") or "") or 5
local CY = tonumber(os.getenv("DS_CY") or "") or 8
local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds
-- ------- real-time pacing
--
-- Each call is one logic step AND one sixtieth of a second of wall clock.
-- The target is carried forward rather than measured from "now" so a slow
-- frame is absorbed by the next one instead of compounding into drift.
local nextAt = nil
local function step()
local now = love.timer.getTime()
nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60)
local slack = nextAt - now
if slack > 0 then
love.timer.sleep(slack)
else
nextAt = now -- fell behind; do not try to catch up
end
coroutine.yield()
end
local function hold(seconds)
for _ = 1, math.floor(seconds * 60) do step() end
end
-- Unpaced, on purpose: a model rebuild is a loading screen, not part of
-- the capture, and there is no reason to watch it at 1x.
local function fast(n)
for _ = 1, n do coroutine.yield() end
end
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
if not StadiumInstall.ready() then
U.log("building stadium models (not part of the capture)...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
fast(1)
guard = guard + 1
end
end
-- STADIUM A stages the fight on the MAP, which wants clear ground. A cave
-- floor often has none, and the mode's answer there is STADIUM B: the two
-- carried discs, which work anywhere. DS_RUNG picks between them per
-- encounter rather than forcing one choice on every location.
OverworldBattle.setting:setValue(os.getenv("DS_RUNG") or "stadium", game)
-- THE PARTY IS BUILT FIRST, at ordinary odds, and the roll is only pinned
-- afterwards. Pokemon.new is where shininess is decided, so setting the
-- odds before this line made the player's own Pikachu shiny too -- and a
-- shiny on the player's side tints that side's pic, which during the intro
-- is the TRAINER, so the player sprite came out discoloured for the whole
-- send-out. The foe is the one this run is about.
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
Shiny.setOdds(1) -- from here on: the encounter
if not game.data.pokemon[SPECIES] then
U.log("no such species: " .. SPECIES)
return
end
if not game.data.maps[MAP] then
U.log("no such map: " .. MAP)
return
end
U.teleport(game, MAP, CX, CY, "down")
hold(1.6) -- let the neighbourhood mesh
hold(LEAD) -- a beat before the fight opens
local battle = BattleState.newWild(game, SPECIES, LEVEL)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- ------- start the shot wider
--
-- BattleCam.zoom is a multiple of the rig's own frame height, so ABOVE one
-- is zoomed OUT -- "the fight in its own landscape", per the note on the
-- constants. 1.15 is fifteen percent wider, which happens to be exactly
-- one notch of the player's own wheel (ZOOM_STEP).
--
-- Set AFTER the battle is pushed, because OverworldBattle's begin path
-- calls BattleCam.reset() and that puts zoom and zoomGoal back to 1. Both
-- are set, not just the goal: leaving the goal alone would make the shot
-- glide outward over the first fifth of a second, and this wants to OPEN
-- wide rather than pull back once the recording has started.
local DS_ZOOM = tonumber(os.getenv("DS_ZOOM") or "") or 1.15
local okCam, BattleCam = pcall(lib.require, "BattleCam")
if okCam and BattleCam then
BattleCam.zoom, BattleCam.zoomGoal = DS_ZOOM, DS_ZOOM
end
local mon = battle.enemy and battle.enemy.mon
U.log(("%s at %s -- shiny=%s zoom=%.2f (1x; close the window when done)")
:format(SPECIES, MAP, tostring(Shiny.isShiny(mon)), DS_ZOOM))
-- The battle stays up, at 1x, until the window is closed by hand. No taps:
-- the recording should be the Pokemon standing there, not a text box being
-- clicked through.
--
-- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set
-- it to a number of seconds and the run should take about that long by the
-- wall clock, which is the only way to prove 1x is actually 1x.
-- DS_SHOTS: capture the ARRIVAL as a strip, for checking that the sparkle
-- fires on its own rather than only when a test arms it by hand. This is
-- the same code path the capture run uses, which is the point -- the last
-- bug here hid precisely because it was verified through a driver that
-- armed the effect itself.
local shots = os.getenv("DS_SHOTS")
if shots then
for k = 1, 26 do
U.shot(game, ("%s/arrive_%02d.png"):format(shots, k))
hold(0.15)
end
local ShinyFx = lib.require("ShinyFx")
local d = ShinyFx.debug or {}
U.log(("fx: armed=%s cleared=%s calls=%s noArena=%s noLive=%s quads=%s")
:format(tostring(d.armed), tostring(d.cleared), tostring(d.calls),
tostring(d.noArena), tostring(d.noLive), tostring(d.quads)))
U.log("arrival strip written to " .. shots)
return
end
local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0
if auto > 0 then
local t0 = love.timer.getTime()
hold(auto)
U.log(("autoclose: asked for %.1fs, took %.2fs")
:format(auto, love.timer.getTime() - t0))
return
end
while true do step() end
end
+643
View File
@@ -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},
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Shiny encounters, one per outdoor place, one window at a time.
#
# bash mods/DramaticShapeVoxelMod/tests/shiny_run.sh
#
# Run from the PROJECT ROOT.
#
# Each Pokemon gets its OWN game window, launched only after the previous one
# has been closed -- the loop blocks on the process, so closing the window is
# what advances it. Nothing is on a timer, so a take can be as long as it
# needs to be, and a bad one can just be closed and re-run.
#
# The battles run at true 1x: tests/shiny_one.lua paces itself against the
# wall clock, because a POKEPORT_DRIVER run is otherwise unpaced and goes as
# fast as the machine can manage.
set -u
LOVE=${LOVE:-/c/Program Files/LOVE/lovec.exe}
DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua
# species | level | map | cell x | cell y | rung
#
# Lapras out on the open sea: ROUTE_20 is the 50-wide water run between
# Fuchsia and Cinnabar, and the middle of it is nothing but water in every
# direction. A species or map this dataset does not have is skipped with a
# line rather than failing the run.
#
# The rung column picks STADIUM A (the fight staged on the map itself) or
# STADIUM B (the two carried discs). Over open water there is no clear GROUND
# to stage on, which is exactly the case B exists for -- see shiny_one.lua.
RUNS=(
"LAPRAS|40|ROUTE_20|25|4|stadium"
)
OPTS="$APPDATA/LOVE/pokemon-love2d/options.lua"
if [ -f "$OPTS" ]; then
cp "$OPTS" "$OPTS.shiny_run_backup"
echo "backed up options.lua"
fi
i=0
for row in "${RUNS[@]}"; do
i=$((i + 1))
IFS='|' read -r SPECIES LEVEL MAP CX CY RUNG <<< "$row"
echo ""
echo "=== $i/${#RUNS[@]} $SPECIES at $MAP ==="
echo " close the window when you are done recording it"
DS_SPECIES="$SPECIES" DS_LEVEL="$LEVEL" DS_MAP="$MAP" \
DS_CX="$CX" DS_CY="$CY" DS_RUNG="${RUNG:-stadium}" \
POKEPORT_DRIVER="$DRIVER" "$LOVE" .
done
# The game persists the whole options table mid-run (OverworldBattle.forceOG),
# so a run leaves the display settings it used on disk. Put them back.
if [ -f "$OPTS.shiny_run_backup" ]; then
cp "$OPTS.shiny_run_backup" "$OPTS"
echo ""
echo "options.lua restored"
fi
echo "done -- ${#RUNS[@]} encounters"
+251
View File
@@ -0,0 +1,251 @@
-- Driver: photograph the shiny system, each case beside its own control.
--
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/shiny_update \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_shots.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- SHOT_DIR must already exist -- the capture writes with io.open.
--
-- EVERY CASE IS SHOT TWICE, the same species on the same tile, once
-- ordinary and once shiny. A single shiny screenshot proves nothing: these
-- are N64 models under a day/night tint on generated ground, and "that
-- looks a bit purple" is not evidence. The pair is.
--
-- The four species are chosen to exercise the four things that can break:
--
-- GYARADOS one of the five Stadium gives a REAL alternate texture, so
-- it runs the explicit colour table rather than the HSL slide.
-- Blue to red, the least deniable shiny in Gen 1.
-- CHARIZARD the biggest slide in the set (H -136, S -6). Also the one
-- whose canonical shiny is famously black, which the Stadium
-- values do NOT reproduce -- they give a dusky slate-violet.
-- Shot precisely so that difference is on the record.
-- GOLBAT L -6, the darkest lightness step there is. If the guard
-- rails are wrong this is where it goes to mud.
-- PONYTA made of fire. Its flames are GENERATED frames, excluded
-- from the recolour, so the correct result is a recoloured
-- body with an ordinary mane. That exclusion is invisible in
-- every other species.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/shiny_update"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- nothing to shoot")
return
end
local Shiny = lib.require("Shiny")
local ShinyFx = lib.require("ShinyFx")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
local StadiumPack = lib.require("StadiumPack")
-- ------- the models
--
-- REV went to 3 with the shiny variants, so an existing cache is stale and
-- the game rebuilds all 151 on the loading screen. That is a minute or so
-- of frames, and it has to be waited out rather than assumed: shooting
-- before it lands gets flat 2D pics and a very confusing set of images.
-- Driven from HERE rather than waited on. The build is normally pumped by
-- the loading screen StadiumScreen.maybePush puts up on the first world
-- frame, but a driver owns the frame loop and that screen never came up --
-- a first run sat at "idle 0/151" for twelve thousand frames and shot four
-- unrecoloured Pokemon. Calling begin/step directly is both faster and
-- honest about what is being tested, which is the extraction, not the
-- screen that usually triggers it.
-- the upgrade question, asked before anything is built: with a stale
-- marker on disk, does this machine know it has work to do?
U.log(("upgrade check: ready=%s usable=%s available=%s pending=%s rom=%s")
:format(tostring(StadiumInstall.ready()),
tostring(StadiumInstall.usable()),
tostring(StadiumInstall.available()),
tostring(StadiumInstall.pending()),
tostring(StadiumInstall.romPresent())))
if not StadiumInstall.ready() then
local ok, err = StadiumInstall.begin()
U.log(("stadium build: begin=%s %s"):format(tostring(ok), tostring(err or "")))
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
-- several species per frame: 151 of them at one a frame is a long
-- wait for no reason, and nothing here needs to be drawn
for _ = 1, 6 do StadiumInstall.step() end
U.wait(1)
guard = guard + 1
local st = StadiumInstall.status
if st and st.state == "failed" then
U.log("stadium build FAILED: " .. tostring(st.error))
break
end
if guard % 10 == 0 then
U.log((" building: %s %d/%d"):format(tostring(st and st.state),
(st and st.done) or 0, (st and st.total) or 0))
end
end
end
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
-- and prove the shiny packs are actually THERE before shooting anything
for _, dex in ipairs({ 130, 6, 42, 77 }) do
U.log((" pack %03d: normal=%s shiny=%s"):format(
dex, tostring(StadiumPack.available(dex, false)),
tostring(StadiumPack.available(dex, true))))
end
-- STADIUM A: models, staged on the map
OverworldBattle.setting:setValue("stadium", game)
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()))
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
local CASES = {
{ "GYARADOS", 40, "ROUTE_1", 5, 8 },
{ "CHARIZARD", 50, "ROUTE_1", 5, 8 },
{ "GOLBAT", 40, "ROUTE_1", 5, 8 },
{ "PONYTA", 40, "ROUTE_1", 5, 8 },
}
-- Odds are the real lever, so the shiny half goes through the SAME path a
-- player's encounter does -- decided inside Pokemon.new by a roll -- rather
-- than being stamped on afterwards. 1 means every mon; a huge denominator
-- means none, which is what makes the control a control.
local function setOdds(shiny)
Shiny.setOdds(shiny and 1 or 100000000)
end
local function toMenu()
for _ = 1, 16 do U.tap(game, "a") U.wait(8) end
end
local function leave()
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
for i, c in ipairs(CASES) do
local species, level, map, cx, cy = c[1], c[2], c[3], c[4], c[5]
if not game.data.pokemon[species] then
U.log("no such species in this dataset: " .. species)
else
for _, variant in ipairs({ "normal", "shiny" }) do
local shiny = (variant == "shiny")
setOdds(shiny)
U.teleport(game, map, cx, cy, "down")
U.wait(90) -- let the neighbourhood's meshes land
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%s %s: isShiny=%s dvs=%s/%s/%s/%s flag=%s"):format(
species, variant, tostring(Shiny.isShiny(mon)),
tostring(mon and mon.dvs and mon.dvs.attack),
tostring(mon and mon.dvs and mon.dvs.defense),
tostring(mon and mon.dvs and mon.dvs.speed),
tostring(mon and mon.dvs and mon.dvs.special),
tostring(mon and mon.shiny)))
-- THE ARRIVAL. Shot during the send-out rather than after it,
-- because the sparkle is three quarters of a second long and the
-- menu is well past it. Three frames close together, so one of them
-- lands mid-burst whatever the intro's pacing does on this map.
U.wait(70)
if shiny then
for k = 1, 3 do
U.shot(game, ("%s/%d_%s_arrival_%d.png")
:format(DIR, i, species:lower(), k))
U.wait(10)
end
end
toMenu()
U.shot(game, ("%s/%d_%s_%s.png")
:format(DIR, i, species:lower(), variant))
-- and the sparkle again, armed deliberately and shot on the next
-- frame. The arrival shots above catch it in its real moment but
-- depend on intro timing; this one is the effect itself, on the
-- record, at a known point in its life.
if shiny then
for k, v in pairs(ShinyFx.debug) do ShinyFx.debug[k] = 0 end
ShinyFx.arm("enemy")
U.wait(4)
U.shot(game, ("%s/%d_%s_sparkle.png"):format(DIR, i, species:lower()))
local d = ShinyFx.debug
U.log((" fx: calls=%d noArena=%d noImage=%d noMesh=%d noLive=%d quads=%d")
:format(d.calls, d.noArena, d.noImage, d.noMesh, d.noLive,
d.quads))
end
leave()
end
end
end
-- ------- the FLAT art
--
-- Everything above is the STADIUM rung, which draws recoloured 3D models
-- and never touches a pic. The tint is the other half of the feature and
-- needs its own rung to be visible at all: 2D-3D stands the game's own
-- battle pics up as cards, which is the path ShinyUI tints.
OverworldBattle.setting:setValue(true, game)
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()) .. " (cards)")
for _, variant in ipairs({ "normal", "shiny" }) do
setOdds(variant == "shiny")
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(90)
local battle = BattleState.newWild(game, "GYARADOS", 40)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.log(("cards %s: isShiny=%s"):format(
variant, tostring(Shiny.isShiny(battle.enemy and battle.enemy.mon))))
U.wait(70)
toMenu()
U.shot(game, ("%s/6_cards_gyarados_%s.png"):format(DIR, variant))
leave()
end
-- ------- the status page
--
-- A shiny and an ordinary mon of the SAME species, so the star is the only
-- difference between the two images.
local SummaryMenu = require("src.ui.SummaryMenu")
for _, variant in ipairs({ "normal", "shiny" }) do
setOdds(variant == "shiny")
local mon = Pokemon.new(game.data, "GYARADOS", 40)
U.log(("summary %s: isShiny=%s flag=%s"):format(
variant, tostring(Shiny.isShiny(mon)), tostring(mon.shiny)))
game.save.party = { mon }
game.stack:push(SummaryMenu.new(game, mon))
U.wait(20)
U.shot(game, ("%s/5_status_%s.png"):format(DIR, variant))
leave()
end
-- ------- the odds actually being odds
--
-- Not a screenshot, but it belongs in the same run: the rate is the thing
-- a player experiences, and it is the one claim a picture cannot make.
Shiny.setOdds(8192)
local n, hits = 4000, 0
for _ = 1, n do
if Shiny.isShiny(Pokemon.new(game.data, "RATTATA", 5)) then
hits = hits + 1
end
end
U.log(("odds check: %d shinies in %d at 1/8192 (expect ~0-2)")
:format(hits, n))
Shiny.setOdds(8192)
U.log("done -- " .. DIR)
end
+331
View File
@@ -0,0 +1,331 @@
-- 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
-- ------- the row on the menu
--
-- The ladder's first rung is both the default and the fallback, so the
-- canonical 1:8192 has to be it: a player who never opens the menu, and a
-- corrupted options.lua, must both land on the games' own rate.
local s = Shiny.setting
eq(s.values[1], 8192, "the ladder starts at the canonical rate")
eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows")
eq(s.labels[#s.labels], "1:1", "the last rung is every encounter")
eq(#s.values, #s.labels, "every rung has a label")
for i = 2, #s.values do
eq(s.values[i] * 2, s.values[i - 1],
("rung %d is twice as often as the one above"):format(i))
end
-- and the roll READS it. Pulled rather than pushed, so a value written by
-- the mod manager's page -- which notifies nobody -- is seen too.
local before = s:read()
s:setValue(512)
Shiny.unpinOdds()
eq(Shiny.odds(), 512, "the roll follows the row without being told")
eq(Shiny.ODDS_DENOM, 512, "and the live field is written through")
s:setIndex(before)
Shiny.setOdds(8192) -- and pinned again, for the blocks below
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)
+50 -3
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
-- Dump every species' ADVANCED palette and its shiny-shifted twin, as JSON.
--
-- luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json
--
-- Run from the PROJECT ROOT.
--
-- The game's battle pics are four-shade DMG grey (0/85/170/255) -- there is
-- no colour in the art at all. Under ADVANCED (`redpp`) the colour comes
-- entirely from a per-species palette applied over the top, which is why a
-- shiny SPRITE is a shifted palette rather than repainted art. This dumps
-- both halves so a comparison sheet can be built from them.
--
-- Reads the real generated data directly rather than going through
-- PaletteFX: the headless fixture dataset carries FIXMON placeholders, not
-- the 151, so a dump driven through it is of nothing.
local POK = dofile("data/generated/pokemon.lua")
local PACK = dofile("data/palettes_gbc.lua")
local V = { path = "mods/DramaticShapeVoxelMod" }
local loaded = {}
function V.require(name)
if loaded[name] == nil then
loaded[name] = assert(loadfile(V.path .. "/lib/" .. name .. ".lua"))(V)
end
return loaded[name]
end
V.mod = { log = { warn = function() end, info = function() end } }
local ShinyPalette = V.require("ShinyPalette")
local mons = POK.pokemon or POK
local rows = {}
for species, def in pairs(mons) do
local dex = type(def) == "table" and def.dex
if type(species) == "string" and dex and dex >= 1 and dex <= 151 then
rows[#rows + 1] = { species = species, dex = dex,
name = def.name or species }
end
end
table.sort(rows, function(a, b) return a.dex < b.dex end)
local function esc(s) return (tostring(s):gsub('"', '\\"')) end
io.write("[\n")
for i, r in ipairs(rows) do
local palName = PACK.pokemon[r.species]
local pal = palName and PACK.palettes[palName]
if pal then
local fn = ShinyPalette.paletteTransform(r.dex)
local n, s = {}, {}
for k = 1, 4 do
local c = pal[k] or pal[#pal]
local cr, cg, cb = c[1], c[2], c[3]
n[k] = ("[%d,%d,%d]"):format(cr, cg, cb)
if fn then
local sr, sg, sb = fn(cr, cg, cb)
s[k] = ("[%d,%d,%d]"):format(sr, sg, sb)
else
s[k] = n[k]
end
end
io.write(('%s{"dex":%d,"species":"%s","name":"%s","pal":"%s",'
.. '"normal":[%s],"shiny":[%s],"shifted":%s}')
:format(i > 1 and ",\n" or "", r.dex, esc(r.species), esc(r.name),
esc(palName), table.concat(n, ","), table.concat(s, ","),
fn and "true" or "false"))
end
end
io.write("\n]\n")
+196
View File
@@ -0,0 +1,196 @@
"""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
HERE = os.path.dirname(os.path.abspath(__file__))
MOD = os.path.dirname(HERE)
# the research tree (gitignored -- see the provenance note above)
SS = os.path.join(MOD, ".claude", "shiny_stadium")
OUT = os.path.join(MOD, "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}
def dominant(dex):
"""The most-covering opaque colour across this species' BODY textures.
Deliberately not the mean: a mean over a Pokemon with a light belly and a
dark back lands on a mid-grey that belongs to neither, and the tint drawn
from it would be no tint. The modal colour is a real colour off the model.
"""
d = slugs[dex]
counts = {}
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
im = Image.open(os.path.join(TEX, "normal", d, fn)).convert("RGBA")
px = im.load()
for y in range(im.size[1]):
for x in range(im.size[0]):
c = px[x, y]
if c[3] < 8:
continue
# skip the near-black and near-white structural colours:
# outlines and eye whites are on every model and say nothing
# about which Pokemon this is
if max(c[:3]) < 30 or min(c[:3]) > 225:
continue
counts[c[:3]] = counts.get(c[:3], 0) + 1
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
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"]))
dom = dominant(dex)
if dom:
w(" -- the colour this species is mostly MADE of, and what its"
" shiny")
w(" -- shift does to it. A flat sprite cannot be recoloured, only")
w(" -- multiplied, and the multiply has to be measured against the")
w(" -- body colour: averaged over a balanced set of references a")
w(" -- hue rotation cancels itself out to no tint at all.")
w(" dom = 0x%02X%02X%02X," % dom)
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))
+128
View File
@@ -0,0 +1,128 @@
"""Build the normal-vs-shiny sprite sheet for all 151.
luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json
python mods/DramaticShapeVoxelMod/tools/shiny_sprite_sheet.py pals.json OUT.png
Run from the PROJECT ROOT.
WHY THIS IS A PALETTE JOB. The game's battle pics carry no colour: they are
four-shade DMG grey (255/170/85/0 plus transparency). Under ADVANCED the
colour comes entirely from a per-species palette laid over that art. So a
"shiny sprite" is the same pixels under a shifted palette -- which is what
this composites, using the real palettes out of data/palettes_gbc.lua and the
real shift out of the mod's own colour tables.
Each Pokemon appears as a PAIR, normal beside shiny, because a lone shiny
sprite says nothing about what changed.
"""
import json
import os
import sys
from PIL import Image, ImageDraw
ROOT = os.getcwd()
FRONT = os.path.join(ROOT, "assets", "generated", "battle", "front")
pals = json.load(open(sys.argv[1], encoding="utf-8"))
OUT = sys.argv[2] if len(sys.argv) > 2 else "shiny_sprites.png"
# the four DMG shades the art is drawn in, lightest first, matching the
# palette's own colour order
SHADES = [255, 170, 85, 0]
SCALE = 2
SW = 40 * SCALE # sprite box
PAD = 3
LABEL = 11
CELL_W = SW * 2 + PAD # a normal/shiny pair
CELL_H = SW + LABEL
COLS = 8 # pairs per row
MARGIN = 10
GAP_X, GAP_Y = 14, 8
def slug_for(species):
"""assets/generated/battle/front filenames, which are not the species key."""
s = species.lower()
cands = [s, s.replace("_", ""), s.replace("_", "."),
s.replace("_f", "f").replace("_m", "m")]
for c in cands:
p = os.path.join(FRONT, c + ".png")
if os.path.exists(p):
return p
return None
def colorize(img, pal):
"""Map the four DMG shades onto a palette. Alpha is carried through."""
src = img.convert("RGBA")
out = Image.new("RGBA", src.size, (0, 0, 0, 0))
sp, op = src.load(), out.load()
for y in range(src.size[1]):
for x in range(src.size[0]):
r, g, b, a = sp[x, y]
if a < 8:
continue
# the art is grey, so any channel identifies the shade; nearest
# rather than exact, because a scaled or re-encoded asset can be
# a unit off
best, bi = None, 0
for i, sh in enumerate(SHADES):
d = abs(r - sh)
if best is None or d < best:
best, bi = d, i
c = pal[bi]
op[x, y] = (c[0], c[1], c[2], a)
return out
rows = (len(pals) + COLS - 1) // COLS
W = MARGIN * 2 + COLS * CELL_W + (COLS - 1) * GAP_X
H = MARGIN * 2 + 34 + rows * (CELL_H + GAP_Y)
sheet = Image.new("RGB", (W, H), (24, 24, 28))
d = ImageDraw.Draw(sheet)
d.text((MARGIN, 8),
"Gen 1 battle sprites -- NORMAL (left) vs SHINY (right) of each pair."
" ADVANCED palettes, shifted by the Stadium shiny values.",
fill=(235, 235, 235))
d.text((MARGIN, 21),
"The art is 4-shade DMG grey; all colour is the palette, so a shiny"
" sprite is the same pixels under a shifted palette.",
fill=(150, 150, 158))
missing = []
for i, e in enumerate(pals):
path = slug_for(e["species"])
cx = MARGIN + (i % COLS) * (CELL_W + GAP_X)
cy = MARGIN + 34 + (i // COLS) * (CELL_H + GAP_Y)
if not path:
missing.append(e["species"])
continue
src = Image.open(path)
n = colorize(src, e["normal"]).resize((SW, SW), Image.NEAREST)
s = colorize(src, e["shiny"]).resize((SW, SW), Image.NEAREST)
# a faint plate behind each half so a dark shiny is not lost on the
# background -- the same plate under both, so it cannot flatter one
d.rectangle([cx, cy, cx + SW - 1, cy + SW - 1], fill=(44, 44, 50))
d.rectangle([cx + SW + PAD, cy, cx + SW * 2 + PAD - 1, cy + SW - 1],
fill=(44, 44, 50))
sheet.paste(n, (cx, cy), n)
sheet.paste(s, (cx + SW + PAD, cy), s)
tag = "%03d %s" % (e["dex"], e["name"][:11])
same = e["normal"] == e["shiny"]
d.text((cx + 1, cy + SW + 1), tag,
fill=(120, 120, 128) if same else (225, 225, 232))
d.text((MARGIN, H - 12),
"grey label = palette identical between the two (that species' shiny"
" does not move this palette)",
fill=(120, 120, 128))
sheet.save(OUT)
print("wrote", OUT, sheet.size)
if missing:
print("no sprite for:", ", ".join(missing))
same_n = sum(1 for e in pals if e["normal"] == e["shiny"])
print("pairs: %d, palettes that shift: %d, identical: %d"
% (len(pals), len(pals) - same_n, same_n))