diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1a20b..fc3e552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5acd476..359cd04 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/data/shiny_colors.lua b/data/shiny_colors.lua new file mode 100644 index 0000000..3b348c4 --- /dev/null +++ b/data/shiny_colors.lua @@ -0,0 +1,2168 @@ +-- Shiny colours for the 151 Stadium models, as the Stadium games define +-- them. GENERATED -- do not hand-edit. The colour model is documented in +-- the header of lib/ShinyPalette.lua. +-- +-- Stadium does not ship a second set of textures for a shiny Pokemon. It +-- slides the colours it already has in HSL: a hue rotation in degrees, +-- plus saturation and lightness on a quantized -8..+8 scale where one +-- step is 12.5% (so +-8 is +-100%, exactly GIMP's Hue-Saturation range -- +-- s = -8 is full greyscale, l = +8 is white). +-- +-- FIVE SPECIES ARE DIFFERENT. Clefairy, Clefable, Jigglypuff, Wigglytuff +-- and Gyarados get a genuine alternate texture in Stadium, because no +-- single slide can produce their shiny: Jigglypuff's body must stay pink +-- while its irises rotate to green, and one rotation moves both or +-- neither. Those five carry `lut` -- an explicit before/after colour +-- mapping sampled from the verified texture pairs, listing only the +-- colours that actually change. A colour absent from the table is left +-- exactly as it was. +-- +-- hueRange is the min/max hue a NON-shiny nicknamed mon can slide to in +-- Stadium (derived from the trainer ID and the nickname). Carried for +-- reference; nothing reads it today. + +return { + [1] = { + name = "bulbasaur", + hueRange = { min = -45, max = 70 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x52BD9C, + slide = { h = -30, l = 2, s = -1 }, + }, + [2] = { + name = "ivysaur", + hueRange = { min = -40, max = 45 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6AC5C5, + slide = { h = -50, l = 2, s = 1 }, + }, + [3] = { + name = "venusaur", + hueRange = { min = -30, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6ABDCD, + slide = { h = -40, l = 2, s = 1 }, + }, + [4] = { + name = "charmander", + hueRange = { min = -25, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEE8B31, + slide = { h = 28, l = 0, s = -1 }, + }, + [5] = { + name = "charmeleon", + hueRange = { min = -10, max = 55 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xD54A6A, + slide = { h = -20, l = 1, s = -1 }, + }, + [6] = { + name = "charizard", + hueRange = { min = -20, max = 15 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF68B29, + slide = { h = -136, l = 0, s = -6 }, + }, + [7] = { + name = "squirtle", + hueRange = { min = -50, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xACFFFF, + slide = { h = 30, l = -3, s = 2 }, + }, + [8] = { + name = "wartortle", + hueRange = { min = -50, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x8BA4E6, + slide = { h = -50, l = -3, s = -2 }, + }, + [9] = { + name = "blastoise", + hueRange = { min = -30, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x7B9CBD, + slide = { h = -35, l = -1, s = -3 }, + }, + [10] = { + name = "caterpie", + hueRange = { min = -30, max = 80 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x629441, + slide = { h = -45, l = 2, s = -1 }, + }, + [11] = { + name = "metapod", + hueRange = { min = -20, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xACF662, + slide = { h = -55, l = 0, s = 0 }, + }, + [12] = { + name = "butterfree", + hueRange = { min = -40, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xD5F6F6, + slide = { h = 140, l = 0, s = 0 }, + }, + [13] = { + name = "weedle", + hueRange = { min = 0, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE67331, + slide = { h = 46, l = 0, s = 0 }, + }, + [14] = { + name = "kakuna", + hueRange = { min = -25, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDECD31, + slide = { h = 30, l = 0, s = 0 }, + }, + [15] = { + name = "beedrill", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFB44A, + slide = { h = 145, l = 0, s = -3 }, + }, + [16] = { + name = "pidgey", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFE6A4, + slide = { h = 40, l = 1, s = -2 }, + }, + [17] = { + name = "pidgeotto", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFE6A4, + slide = { h = 40, l = 1, s = 0 }, + }, + [18] = { + name = "pidgeot", + hueRange = { min = -35, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFE6A4, + slide = { h = 40, l = 1, s = -3 }, + }, + [19] = { + name = "rattata", + hueRange = { min = -40, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x9C6AC5, + slide = { h = -105, l = 0, s = -5 }, + }, + [20] = { + name = "raticate", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6EEC5, + slide = { h = -30, l = 1, s = -4 }, + }, + [21] = { + name = "spearow", + hueRange = { min = -30, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFE6A4, + slide = { h = -25, l = 1, s = -2 }, + }, + [22] = { + name = "fearow", + hueRange = { min = -25, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDCD294, + slide = { h = 140, l = 3, s = -5 }, + }, + [23] = { + name = "ekans", + hueRange = { min = -30, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xAC7BBD, + slide = { h = -155, l = -2, s = -5 }, + }, + [24] = { + name = "arbok", + hueRange = { min = -70, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA48BBD, + slide = { h = -80, l = 0, s = -4 }, + }, + [25] = { + name = "pikachu", + hueRange = { min = -15, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFDE31, + slide = { h = -25, l = 1, s = 0 }, + }, + [26] = { + name = "raichu", + hueRange = { min = -15, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6A429, + slide = { h = 25, l = 0, s = -2 }, + }, + [27] = { + name = "sandshrew", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA49C52, + slide = { h = -159, l = -1, s = -4 }, + }, + [28] = { + name = "sandslash", + hueRange = { min = -20, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xCDBD08, + slide = { h = 25, l = 1, s = -6 }, + }, + [29] = { + name = "nidoran_f", + hueRange = { min = -60, max = 70 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xD5DEF6, + slide = { h = 70, l = -2, s = 2 }, + }, + [30] = { + name = "nidorina", + hueRange = { min = -80, max = 80 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x9CD5DE, + slide = { h = 100, l = 0, s = 0 }, + }, + [31] = { + name = "nidoqueen", + hueRange = { min = -60, max = 80 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x7B9CBD, + slide = { h = 50, l = 0, s = -5 }, + }, + [32] = { + name = "nidoran_m", + hueRange = { min = -70, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF694D5, + slide = { h = -96, l = 0, s = 0 }, + }, + [33] = { + name = "nidorino", + hueRange = { min = -70, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE68BCD, + slide = { h = -99, l = 0, s = 0 }, + }, + [34] = { + name = "nidoking", + hueRange = { min = -50, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD83C5, + slide = { h = -60, l = 0, s = 0 }, + }, + [35] = { + name = "clefairy", + hueRange = { min = -40, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFC5C5, + -- Stadium ships a real alternate texture for this one; 350 + -- colours move, every other colour stays exactly as it was. + lut = { + [0x080800]=0x010800, [0x100800]=0x020F00, [0x101000]=0x020F00, [0x181000]=0x031700, + [0x181008]=0x081905, [0x201000]=0x041E00, [0x201800]=0x041E00, [0x201808]=0x082204, + [0x291010]=0x29101D, [0x291800]=0x052700, [0x291808]=0x082C02, [0x291818]=0x291820, + [0x292000]=0x052700, [0x310808]=0x31081D, [0x311010]=0x311021, [0x311818]=0x311825, + [0x312000]=0x062F00, [0x312008]=0x083501, [0x312010]=0x0F340A, [0x312020]=0x312028, + [0x312900]=0x062F00, [0x312908]=0x083501, [0x312910]=0x0F340A, [0x390000]=0x39001D, + [0x390008]=0x39001D, [0x390808]=0x390821, [0x391818]=0x391829, [0x392020]=0x39202D, + [0x392900]=0x073600, [0x392908]=0x083E00, [0x392910]=0x0F3D08, [0x392918]=0x173C11, + [0x392929]=0x392931, [0x392939]=0x392931, [0x393108]=0x083E00, [0x410000]=0x410021, + [0x410008]=0x410021, [0x410808]=0x410825, [0x410810]=0x410825, [0x412020]=0x412031, + [0x412908]=0x094500, [0x412910]=0x0F4607, [0x412929]=0x412935, [0x413108]=0x094500, + [0x413110]=0x0F4607, [0x413118]=0x174510, [0x4A0008]=0x4A0025, [0x4A0808]=0x4A0829, + [0x4A0810]=0x4A0829, [0x4A2020]=0x4A2035, [0x4A3108]=0x0A4E00, [0x4A3110]=0x0F5006, + [0x4A3118]=0x174F0E, [0x4A3131]=0x4A313E, [0x4A3910]=0x0F5006, [0x4A3918]=0x174F0E, + [0x520810]=0x52082D, [0x521010]=0x521031, [0x521018]=0x521031, [0x521820]=0x521835, + [0x522929]=0x52293E, [0x523131]=0x523142, [0x523910]=0x105904, [0x523931]=0x523142, + [0x523939]=0x523946, [0x524110]=0x105904, [0x524118]=0x17580D, [0x5A1010]=0x5A1035, + [0x5A2020]=0x5A203D, [0x5A3939]=0x5A394A, [0x5A4108]=0x0C5D00, [0x5A4110]=0x106203, + [0x5A4118]=0x17600C, [0x5A4120]=0x1F5F15, [0x5A4129]=0x275E1F, [0x5A4139]=0x5A394A, + [0x5A4141]=0x5A414E, [0x5A414A]=0x5A414E, [0x5A4A18]=0x17600C, [0x5A4A20]=0x1F5F15, + [0x621010]=0x621039, [0x622929]=0x622946, [0x623131]=0x62314A, [0x623939]=0x62394E, + [0x624110]=0x106B02, [0x624141]=0x624152, [0x624A10]=0x106B02, [0x624A18]=0x17690A, + [0x624A20]=0x1F6813, [0x624A29]=0x27671D, [0x624A41]=0x624152, [0x625220]=0x1F6813, + [0x6A1010]=0x6A103D, [0x6A2931]=0x6A294A, [0x6A3131]=0x6A314E, [0x6A3939]=0x6A3952, + [0x6A4141]=0x6A4156, [0x6A4A31]=0x2F6E25, [0x6A4A4A]=0x6A4A5A, [0x6A5220]=0x1F7112, + [0x6A5241]=0x6A4156, [0x734141]=0x73415A, [0x734A4A]=0x734A5E, [0x735231]=0x2F7824, + [0x735241]=0x73415A, [0x73524A]=0x734A5E, [0x735252]=0x735263, [0x73525A]=0x735263, + [0x735A4A]=0x734A5E, [0x7B2018]=0x7B184A, [0x7B4A4A]=0x7B4A63, [0x7B5252]=0x7B5266, + [0x7B5A41]=0x7B415E, [0x7B5A4A]=0x7B4A63, [0x7B5A52]=0x7B5266, [0x7B5A5A]=0x7B5A6B, + [0x7B625A]=0x7B5A6B, [0x832018]=0x83184E, [0x832020]=0x832052, [0x83414A]=0x834162, + [0x834A52]=0x834A67, [0x835252]=0x83526A, [0x835A5A]=0x835A6E, [0x83624A]=0x834A67, + [0x83625A]=0x835A6E, [0x8B3939]=0x8B3962, [0x8B4A4A]=0x8B4A6A, [0x8B5252]=0x8B526F, + [0x8B5A5A]=0x8B5A72, [0x8B625A]=0x8B5A72, [0x8B6262]=0x8B6277, [0x8B6A52]=0x8B526F, + [0x941818]=0x941856, [0x942018]=0x941856, [0x94525A]=0x945273, [0x945A5A]=0x945A77, + [0x946262]=0x94627B, [0x946A62]=0x94627B, [0x946A6A]=0x946A7F, [0x946A73]=0x946A7F, + [0x94735A]=0x945A77, [0x9C2020]=0x9C205E, [0x9C2920]=0x9C205E, [0x9C2929]=0x9C2963, + [0x9C5A5A]=0x9C5A7B, [0x9C5A62]=0x9C5A7B, [0x9C6262]=0x9C627F, [0x9C626A]=0x9C627F, + [0x9C6A6A]=0x9C6A83, [0x9C6A73]=0x9C6A83, [0x9C735A]=0x9C5A7B, [0x9C7362]=0x9C627F, + [0x9C7373]=0x9C7388, [0x9C737B]=0x9C7388, [0xA42929]=0xA42967, [0xA43129]=0xA42967, + [0xA43131]=0xA4316B, [0xA4626A]=0xA46283, [0xA46A6A]=0xA46A87, [0xA47373]=0xA4738C, + [0xA47B62]=0xA46283, [0xA47B6A]=0xA46A87, [0xA47B73]=0xA4738C, [0xA47B7B]=0xA47B90, + [0xA47B83]=0xA47B90, [0xA4836A]=0xA46A87, [0xA48383]=0xA48393, [0xAC3131]=0xAC316F, + [0xAC3931]=0xAC316F, [0xAC3939]=0xAC3973, [0xAC4A41]=0xAC4177, [0xAC6A6A]=0xAC6A8B, + [0xAC7373]=0xAC7390, [0xAC737B]=0xAC7390, [0xAC7B7B]=0xAC7B94, [0xAC8373]=0xAC7390, + [0xAC837B]=0xAC7B94, [0xAC8383]=0xAC8398, [0xAC838B]=0xAC8398, [0xAC8B83]=0xAC8398, + [0xAC8B8B]=0xAC8B9C, [0xAC948B]=0xAC8B9C, [0xB44139]=0xB43977, [0xB44141]=0xB4417B, + [0xB44A41]=0xB4417B, [0xB44A4A]=0xB44A7F, [0xB45252]=0xB45283, [0xB47373]=0xB47394, + [0xB47B7B]=0xB47B98, [0xB48383]=0xB4839C, [0xB48B7B]=0xB47B98, [0xB48B8B]=0xB48BA0, + [0xB48B94]=0xB48BA0, [0xB49494]=0xB494A4, [0xBD4141]=0xBD417F, [0xBD4A41]=0xBD417F, + [0xBD4A4A]=0xBD4A84, [0xBD524A]=0xBD4A84, [0xBD5252]=0xBD5288, [0xBD5A52]=0xBD5288, + [0xBD5A5A]=0xBD5A8C, [0xBD7B7B]=0xBD7B9C, [0xBD8383]=0xBD83A0, [0xBD8B83]=0xBD83A0, + [0xBD8B8B]=0xBD8BA4, [0xBD8B94]=0xBD8BA4, [0xBD947B]=0xBD7B9C, [0xBD9483]=0xBD83A0, + [0xBD948B]=0xBD8BA4, [0xBD9494]=0xBD94A9, [0xBD949C]=0xBD94A9, [0xBDA49C]=0xBD9CAC, + [0xBDA4A4]=0xBDA4B0, [0xBDA4AC]=0xBDA4B0, [0xBDACA4]=0xBDA4B0, [0xC5524A]=0xC54A88, + [0xC55252]=0xC5528C, [0xC56262]=0xC56294, [0xC56A6A]=0xC56A98, [0xC58383]=0xC583A4, + [0xC58B8B]=0xC58BA8, [0xC5948B]=0xC58BA8, [0xC59494]=0xC594AC, [0xC59C8B]=0xC58BA8, + [0xC59C94]=0xC594AC, [0xC59C9C]=0xC59CB0, [0xC59CA4]=0xC59CB0, [0xC5ACAC]=0xC5ACB9, + [0xCD5252]=0xCD5290, [0xCD5A52]=0xCD5290, [0xCD5A5A]=0xCD5A94, [0xCD625A]=0xCD5A94, + [0xCD6262]=0xCD6298, [0xCD6A62]=0xCD6298, [0xCD736A]=0xCD6A9C, [0xCD7373]=0xCD73A0, + [0xCD8B8B]=0xCD8BAC, [0xCD9494]=0xCD94B0, [0xCD9C94]=0xCD94B0, [0xCD9C9C]=0xCD9CB4, + [0xCD9CA4]=0xCD9CB4, [0xCDA4A4]=0xCDA4B9, [0xCDACAC]=0xCDACBC, [0xCDB4B4]=0xCDB4C0, + [0xD5625A]=0xD55A98, [0xD56262]=0xD5629C, [0xD56A62]=0xD5629C, [0xD57373]=0xD573A4, + [0xD58B8B]=0xD58BB0, [0xD5948B]=0xD58BB0, [0xD59494]=0xD594B5, [0xD59C94]=0xD594B5, + [0xD59C9C]=0xD59CB9, [0xD5A49C]=0xD59CB9, [0xD5A4A4]=0xD5A4BC, [0xD5A4AC]=0xD5A4BC, + [0xD5ACAC]=0xD5ACC0, [0xD5B4B4]=0xD5B4C4, [0xD5BDBD]=0xD5BDC9, [0xD5C5C5]=0xD5C5CD, + [0xDE6A62]=0xDE62A0, [0xDE6A6A]=0xDE6AA4, [0xDE736A]=0xDE6AA4, [0xDE7373]=0xDE73A9, + [0xDE7B73]=0xDE73A9, [0xDE7B7B]=0xDE7BAD, [0xDE9494]=0xDE94B9, [0xDE9C94]=0xDE94B9, + [0xDE9C9C]=0xDE9CBD, [0xDEA4A4]=0xDEA4C1, [0xDEA4AC]=0xDEA4C1, [0xDEACA4]=0xDEA4C1, + [0xDEACAC]=0xDEACC5, [0xDEACB4]=0xDEACC5, [0xDEB4B4]=0xDEB4C9, [0xDEBDBD]=0xDEBDCE, + [0xDEC5C5]=0xDEC5D2, [0xE6736A]=0xE66AA8, [0xE67373]=0xE673AD, [0xE67B73]=0xE673AD, + [0xE67B7B]=0xE67BB1, [0xE67B8B]=0xE67BB1, [0xE67B94]=0xE67BB1, [0xE6837B]=0xE67BB1, + [0xE68383]=0xE683B4, [0xE68394]=0xE683B4, [0xE68B83]=0xE683B4, [0xE69494]=0xE694BD, + [0xE69CA4]=0xE69CC1, [0xE6A4A4]=0xE6A4C5, [0xE6A4AC]=0xE6A4C5, [0xE6ACA4]=0xE6A4C5, + [0xE6ACAC]=0xE6ACC9, [0xE6ACB4]=0xE6ACC9, [0xE6B4B4]=0xE6B4CD, [0xE6B4BD]=0xE6B4CD, + [0xE6BDBD]=0xE6BDD2, [0xE6C5C5]=0xE6C5D6, [0xE6CDCD]=0xE6CDDA, [0xE6CDD5]=0xE6CDDA, + [0xEE7B7B]=0xEE7BB5, [0xEE837B]=0xEE7BB5, [0xEE8383]=0xEE83B9, [0xEE8394]=0xEE83B9, + [0xEE8B83]=0xEE83B9, [0xEE8B8B]=0xEE8BBD, [0xEE8B9C]=0xEE8BBD, [0xEE948B]=0xEE8BBD, + [0xEE9494]=0xEE94C1, [0xEE949C]=0xEE94C1, [0xEE94A4]=0xEE94C1, [0xEE9CA4]=0xEE9CC5, + [0xEEA4AC]=0xEEA4C9, [0xEEACAC]=0xEEACCD, [0xEEACB4]=0xEEACCD, [0xEEB4AC]=0xEEACCD, + [0xEEB4B4]=0xEEB4D1, [0xEEB4BD]=0xEEB4D1, [0xEEBDB4]=0xEEB4D1, [0xEEBDBD]=0xEEBDD6, + [0xEEC5BD]=0xEEBDD6, [0xEEC5C5]=0xEEC5DA, [0xEECDCD]=0xEECDDE, [0xEED5D5]=0xEED5E2, + [0xF69CA4]=0xF69CC9, [0xF69CAC]=0xF69CC9, [0xF6A4AC]=0xF6A4CD, [0xF6ACAC]=0xF6ACD1, + [0xF6ACB4]=0xF6ACD1, [0xF6B4B4]=0xF6B4D5, [0xF6B4BD]=0xF6B4D5, [0xF6BDB4]=0xF6B4D5, + [0xF6BDBD]=0xF6BDDA, [0xF6BDC5]=0xF6BDDA, [0xF6C5C5]=0xF6C5DE, [0xF6CDCD]=0xF6CDE2, + [0xF6D5D5]=0xF6D5E6, [0xFFB4B4]=0xFFB4DA, [0xFFB4BD]=0xFFB4DA, [0xFFBDBD]=0xFFBDDE, + [0xFFBDC5]=0xFFBDDE, [0xFFC5BD]=0xFFBDDE, [0xFFC5C5]=0xFFC5E2, [0xFFC5CD]=0xFFC5E2, + [0xFFCDC5]=0xFFC5E2, [0xFFCDCD]=0xFFCDE6, [0xFFD5CD]=0xFFCDE6, [0xFFD5D5]=0xFFD5EA, + [0xFFDEDE]=0xFFDEEE, [0xFFE6E6]=0xFFE6F2, + }, + }, + [36] = { + name = "clefable", + hueRange = { min = -40, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFD5CD, + -- Stadium ships a real alternate texture for this one; 223 + -- colours move, every other colour stays exactly as it was. + lut = { + [0x080000]=0x080004, [0x080800]=0x010800, [0x100808]=0x10080C, [0x180000]=0x18000C, + [0x180808]=0x180810, [0x181010]=0x181014, [0x200000]=0x200010, [0x200808]=0x200814, + [0x201010]=0x201018, [0x290000]=0x290015, [0x290808]=0x290819, [0x291010]=0x29101D, + [0x292018]=0x291820, [0x310000]=0x310019, [0x310808]=0x31081D, [0x311010]=0x311021, + [0x312020]=0x312028, [0x312029]=0x312028, [0x390008]=0x39001D, [0x391010]=0x391025, + [0x391818]=0x391829, [0x392929]=0x392931, [0x393129]=0x392931, [0x410000]=0x410021, + [0x410808]=0x410825, [0x413129]=0x412935, [0x4A0000]=0x4A0025, [0x4A3131]=0x4A313E, + [0x520000]=0x520029, [0x521000]=0x520029, [0x523939]=0x523946, [0x524139]=0x523946, + [0x5A0000]=0x5A002D, [0x5A1808]=0x5A0831, [0x5A4141]=0x5A414E, [0x5A4A41]=0x5A414E, + [0x620000]=0x620031, [0x622010]=0x621039, [0x622020]=0x622041, [0x622918]=0x62183D, + [0x6A0000]=0x6A0035, [0x6A2918]=0x6A1841, [0x6A2920]=0x6A2045, [0x6A3120]=0x6A2045, + [0x730000]=0x73003A, [0x733929]=0x73294E, [0x735252]=0x735263, [0x735A52]=0x735263, + [0x7B4131]=0x7B3156, [0x7B5252]=0x7B5266, [0x7B625A]=0x7B5A6B, [0x834141]=0x834162, + [0x834A39]=0x83395E, [0x834A41]=0x834162, [0x835241]=0x834162, [0x83625A]=0x835A6E, + [0x8B4A4A]=0x8B4A6A, [0x94524A]=0x944A6F, [0x945A5A]=0x945A77, [0x946A6A]=0x946A7F, + [0x94736A]=0x946A7F, [0x9C5252]=0x9C5277, [0x9C6A62]=0x9C627F, [0x9C7362]=0x9C627F, + [0x9C7B73]=0x9C7388, [0xA4524A]=0xA44A77, [0xA46262]=0xA46283, [0xA47362]=0xA46283, + [0xA4736A]=0xA46A87, [0xA47B6A]=0xA46A87, [0xA4837B]=0xA47B90, [0xA48383]=0xA48393, + [0xA48B83]=0xA48393, [0xAC736A]=0xAC6A8B, [0xAC8373]=0xAC7390, [0xAC8B83]=0xAC8398, + [0xAC8B8B]=0xAC8B9C, [0xAC948B]=0xAC8B9C, [0xB4948B]=0xB48BA0, [0xB49494]=0xB494A4, + [0xB49C94]=0xB494A4, [0xBD6262]=0xBD6290, [0xBD7373]=0xBD7398, [0xBD9C94]=0xBD94A9, + [0xBD9C9C]=0xBD9CAC, [0xBDA494]=0xBD94A9, [0xBDA49C]=0xBD9CAC, [0xBDA4A4]=0xBDA4B0, + [0xC5625A]=0xC55A90, [0xC5736A]=0xC56A98, [0xC57B73]=0xC5739C, [0xC59C8B]=0xC58BA8, + [0xC59C94]=0xC594AC, [0xC5A494]=0xC594AC, [0xC5A49C]=0xC59CB0, [0xC5AC9C]=0xC59CB0, + [0xC5ACA4]=0xC5A4B4, [0xCD949C]=0xCD94B0, [0xCD9C9C]=0xCD9CB4, [0xCDA49C]=0xCD9CB4, + [0xCDA4A4]=0xCDA4B9, [0xCDAC9C]=0xCD9CB4, [0xCDACA4]=0xCDA4B9, [0xCDB4A4]=0xCDA4B9, + [0xCDB4AC]=0xCDACBC, [0xCDB4B4]=0xCDB4C0, [0xCDBDB4]=0xCDB4C0, [0xD56A6A]=0xD56AA0, + [0xD594A4]=0xD594B5, [0xD59C9C]=0xD59CB9, [0xD59CA4]=0xD59CB9, [0xD59CAC]=0xD59CB9, + [0xD5A4A4]=0xD5A4BC, [0xD5A4AC]=0xD5A4BC, [0xD5ACA4]=0xD5A4BC, [0xD5ACAC]=0xD5ACC0, + [0xD5ACB4]=0xD5ACC0, [0xD5B4A4]=0xD5A4BC, [0xD5B4AC]=0xD5ACC0, [0xD5B4B4]=0xD5B4C4, + [0xD5BDAC]=0xD5ACC0, [0xD5BDB4]=0xD5B4C4, [0xD5C5BD]=0xD5BDC9, [0xDE7B73]=0xDE73A9, + [0xDE94A4]=0xDE94B9, [0xDE9CA4]=0xDE9CBD, [0xDE9CAC]=0xDE9CBD, [0xDEA4AC]=0xDEA4C1, + [0xDEA4B4]=0xDEA4C1, [0xDEACB4]=0xDEACC5, [0xDEACBD]=0xDEACC5, [0xDEB4AC]=0xDEACC5, + [0xDEB4B4]=0xDEB4C9, [0xDEBDAC]=0xDEACC5, [0xDEBDB4]=0xDEB4C9, [0xDEBDBD]=0xDEBDCE, + [0xDEC5B4]=0xDEB4C9, [0xDEC5BD]=0xDEBDCE, [0xDECDBD]=0xDEBDCE, [0xDECDC5]=0xDEC5D2, + [0xE67373]=0xE673AD, [0xE67B73]=0xE673AD, [0xE68B9C]=0xE68BB9, [0xE6949C]=0xE694BD, + [0xE694A4]=0xE694BD, [0xE694AC]=0xE694BD, [0xE69CA4]=0xE69CC1, [0xE69CAC]=0xE69CC1, + [0xE69CB4]=0xE69CC1, [0xE6A4AC]=0xE6A4C5, [0xE6A4B4]=0xE6A4C5, [0xE6ACA4]=0xE6A4C5, + [0xE6ACB4]=0xE6ACC9, [0xE6B4AC]=0xE6ACC9, [0xE6BDB4]=0xE6B4CD, [0xE6BDBD]=0xE6BDD2, + [0xE6C5B4]=0xE6B4CD, [0xE6C5BD]=0xE6BDD2, [0xE6C5C5]=0xE6C5D6, [0xE6CDBD]=0xE6BDD2, + [0xE6CDC5]=0xE6C5D6, [0xE6D5CD]=0xE6CDDA, [0xEE7373]=0xEE73B1, [0xEE7B73]=0xEE73B1, + [0xEE8B9C]=0xEE8BBD, [0xEE949C]=0xEE94C1, [0xEE94A4]=0xEE94C1, [0xEE9CA4]=0xEE9CC5, + [0xEE9CAC]=0xEE9CC5, [0xEE9CB4]=0xEE9CC5, [0xEEA4AC]=0xEEA4C9, [0xEEA4B4]=0xEEA4C9, + [0xEEA4BD]=0xEEA4C9, [0xEEACB4]=0xEEACCD, [0xEEACBD]=0xEEACCD, [0xEEB4BD]=0xEEB4D1, + [0xEEBDB4]=0xEEB4D1, [0xEEC5B4]=0xEEB4D1, [0xEEC5BD]=0xEEBDD6, [0xEEC5C5]=0xEEC5DA, + [0xEECDBD]=0xEEBDD6, [0xEECDC5]=0xEEC5DA, [0xEECDCD]=0xEECDDE, [0xEED5C5]=0xEEC5DA, + [0xEED5CD]=0xEECDDE, [0xEEDED5]=0xEED5E2, [0xF68B9C]=0xF68BC1, [0xF6949C]=0xF694C5, + [0xF694A4]=0xF694C5, [0xF69CA4]=0xF69CC9, [0xF69CAC]=0xF69CC9, [0xF6A4AC]=0xF6A4CD, + [0xF6A4B4]=0xF6A4CD, [0xF6AC9C]=0xF69CC9, [0xF6BDAC]=0xF6ACD1, [0xF6C5B4]=0xF6B4D5, + [0xF6C5BD]=0xF6BDDA, [0xF6CDBD]=0xF6BDDA, [0xF6CDC5]=0xF6C5DE, [0xF6D5C5]=0xF6C5DE, + [0xF6D5CD]=0xF6CDE2, [0xF6D5D5]=0xF6D5E6, [0xF6DECD]=0xF6CDE2, [0xF6DED5]=0xF6D5E6, + [0xF6E6DE]=0xF6DEEA, [0xFF8B9C]=0xFF8BC5, [0xFF949C]=0xFF94CA, [0xFF94A4]=0xFF94CA, + [0xFF9CA4]=0xFF9CCE, [0xFFC5B4]=0xFFB4DA, [0xFFCDBD]=0xFFBDDE, [0xFFCDC5]=0xFFC5E2, + [0xFFD5C5]=0xFFC5E2, [0xFFD5CD]=0xFFCDE6, [0xFFDECD]=0xFFCDE6, [0xFFDED5]=0xFFD5EA, + [0xFFE6D5]=0xFFD5EA, [0xFFE6DE]=0xFFDEEE, [0xFFEEDE]=0xFFDEEE, [0xFFEEE6]=0xFFE6F2, + [0xFFEEEE]=0xFFEEF6, [0xFFF6EE]=0xFFEEF6, [0xFFF6F6]=0xFFF6FB, + }, + }, + [37] = { + name = "vulpix", + hueRange = { min = -30, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xCD9C8B, + slide = { h = 27, l = 0, s = 3 }, + }, + [38] = { + name = "ninetales", + hueRange = { min = -30, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFEEB4, + slide = { h = -130, l = 0, s = -2 }, + }, + [39] = { + name = "jigglypuff", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFCDDE, + -- Stadium ships a real alternate texture for this one; 394 + -- colours move, every other colour stays exactly as it was. + lut = { + [0x002018]=0x122000, [0x082018]=0x152206, [0x082920]=0x1A2B06, [0x083931]=0x233D04, + [0x103931]=0x273C0D, [0x104139]=0x2B450C, [0x104A39]=0x304E0C, [0x183939]=0x2A3B16, + [0x184139]=0x2F4415, [0x184A39]=0x344E14, [0x184A41]=0x344E14, [0x18524A]=0x385614, + [0x185A4A]=0x3D5F13, [0x204141]=0x32431E, [0x204A41]=0x374D1D, [0x204A4A]=0x374D1D, + [0x20524A]=0x3C561C, [0x205A52]=0x405E1C, [0x206252]=0x45671B, [0x20625A]=0x45671B, + [0x206A5A]=0x49701A, [0x206A62]=0x49701A, [0x294141]=0x364327, [0x294A41]=0x3B4C27, + [0x294A4A]=0x3B4C27, [0x29524A]=0x405526, [0x295252]=0x405526, [0x295A52]=0x445E25, + [0x295A5A]=0x445E25, [0x296252]=0x496625, [0x29625A]=0x496625, [0x296A5A]=0x4D6F24, + [0x296A62]=0x4D6F24, [0x297362]=0x527923, [0x29736A]=0x527923, [0x31524A]=0x43542F, + [0x315252]=0x43542F, [0x315A52]=0x485D2E, [0x315A5A]=0x485D2E, [0x31625A]=0x4C662D, + [0x316A62]=0x516E2D, [0x317362]=0x56782C, [0x31736A]=0x56782C, [0x317B6A]=0x5A812B, + [0x317B73]=0x5A812B, [0x395252]=0x475437, [0x395A52]=0x4B5C37, [0x395A5A]=0x4B5C37, + [0x395A62]=0x506536, [0x39625A]=0x506536, [0x396262]=0x506536, [0x396A62]=0x546E35, + [0x39736A]=0x597735, [0x397B6A]=0x5E8034, [0x397B73]=0x5E8034, [0x398373]=0x628933, + [0x398B7B]=0x679133, [0x415A5A]=0x4F5C3F, [0x416262]=0x53643F, [0x416A6A]=0x586D3E, + [0x41736A]=0x5D773D, [0x417373]=0x5D773D, [0x417B6A]=0x617F3D, [0x417B73]=0x617F3D, + [0x418373]=0x66883C, [0x41837B]=0x66883C, [0x418B7B]=0x6A913B, [0x418B83]=0x6A913B, + [0x419483]=0x6F9A3B, [0x41948B]=0x6F9A3B, [0x41A494]=0x78AB3A, [0x4A3139]=0x483348, + [0x4A6A6A]=0x5C6C48, [0x4A6A73]=0x617647, [0x4A7373]=0x617647, [0x4A7B73]=0x657F46, + [0x4A837B]=0x6A8746, [0x4A8B83]=0x6E9045, [0x4A9483]=0x739A44, [0x4A948B]=0x739A44, + [0x4A9C8B]=0x78A244, [0x4AA494]=0x7CAB43, [0x4AAC9C]=0x81B343, [0x523139]=0x503350, + [0x523141]=0x503350, [0x523941]=0x503B50, [0x527373]=0x647550, [0x52737B]=0x697E4F, + [0x527B7B]=0x697E4F, [0x528383]=0x6D874E, [0x52948B]=0x77994D, [0x529C8B]=0x7BA24C, + [0x529C94]=0x7BA24C, [0x52A494]=0x80AA4C, [0x52A49C]=0x80AA4C, [0x52AC9C]=0x84B34B, + [0x5A3941]=0x583B58, [0x5A394A]=0x583B58, [0x5A414A]=0x584358, [0x5A4152]=0x584358, + [0x5A737B]=0x6C7D58, [0x5A7B7B]=0x6C7D58, [0x5A7B83]=0x718657, [0x5A837B]=0x718657, + [0x5A8383]=0x718657, [0x5A948B]=0x7A9856, [0x5A9C94]=0x7FA155, [0x5AA494]=0x83AA54, + [0x5AA49C]=0x83AA54, [0x5AAC9C]=0x88B254, [0x5AACA4]=0x88B254, [0x5AB4A4]=0x8CBB53, + [0x62394A]=0x5F3C5F, [0x62414A]=0x604360, [0x62A49C]=0x87A95D, [0x62AC9C]=0x8BB25C, + [0x62B4A4]=0x90BA5C, [0x62B4AC]=0x90BA5C, [0x6A414A]=0x674467, [0x6A948B]=0x819767, + [0x6AB4A4]=0x93BA64, [0x6AB4AC]=0x93BA64, [0x6ABDAC]=0x98C364, [0x6ABDB4]=0x98C364, + [0x6AC5B4]=0x9DCC63, [0x6AC5BD]=0x9DCC63, [0x73525A]=0x715471, [0x73C5B4]=0xA1CB6D, + [0x73C5BD]=0xA1CB6D, [0x7B4A5A]=0x774E77, [0x7B525A]=0x785578, [0x7B5A62]=0x795C79, + [0x7B9CA4]=0x92A778, [0x7BB4AC]=0x9BB877, [0x7BBDB4]=0xA0C276, [0x7BC5B4]=0xA4CB75, + [0x7BC5BD]=0xA4CB75, [0x7BCDBD]=0xA9D375, [0x7BCDC5]=0xA9D375, [0x7BD5C5]=0xADDC74, + [0x83525A]=0x7F567F, [0x835262]=0x7F567F, [0x835A62]=0x805D80, [0x835A6A]=0x805D80, + [0x839CA4]=0x95A681, [0x83A4A4]=0x95A681, [0x83ACA4]=0x9AAF80, [0x83C5BD]=0xA8CA7E, + [0x83CDC5]=0xACD37D, [0x83D5C5]=0xB1DB7D, [0x83D5CD]=0xB1DB7D, [0x83DECD]=0xB6E57C, + [0x8B5262]=0x875687, [0x8B5A62]=0x875E87, [0x8B5A6A]=0x875E87, [0x8B626A]=0x886588, + [0x8BA4AC]=0x9DAE89, [0x8BB4AC]=0xA2B788, [0x8BB4B4]=0xA2B788, [0x8BBDB4]=0xA7C187, + [0x8BCDC5]=0xB0D286, [0x8BD5CD]=0xB4DB85, [0x945A6A]=0x905E90, [0x94626A]=0x906690, + [0x946273]=0x906690, [0x946A73]=0x916D91, [0x946A7B]=0x916D91, [0x94ACB4]=0xA6B692, + [0x94B4AC]=0xA6B692, [0x94B4B4]=0xA6B692, [0x94BDB4]=0xABC091, [0x94D5CD]=0xB8DA8F, + [0x94DED5]=0xBDE48E, [0x94EEE6]=0xC6F58D, [0x9C6273]=0x986698, [0x9C6A73]=0x986E98, + [0x9C6A7B]=0x986E98, [0x9C737B]=0x997699, [0x9C7383]=0x997699, [0x9CB4BD]=0xAEBF9A, + [0x9CBDBD]=0xAEBF9A, [0x9CC5BD]=0xB3C899, [0x9CC5C5]=0xB3C899, [0x9CDED5]=0xC1E397, + [0x9CE6DE]=0xC5EC96, [0xA44141]=0x9D489D, [0xA46A7B]=0xA06EA0, [0xA4737B]=0xA077A0, + [0xA47383]=0xA077A0, [0xA47B83]=0xA17EA1, [0xA47B8B]=0xA17EA1, [0xA4838B]=0xA285A2, + [0xA4BDBD]=0xB2BFA2, [0xA4CDC5]=0xBBD0A1, [0xA4E6DE]=0xC9EB9F, [0xA4EEDE]=0xCDF49E, + [0xAC4A4A]=0xA551A5, [0xAC4A52]=0xA551A5, [0xAC525A]=0xA559A5, [0xAC7383]=0xA877A8, + [0xAC838B]=0xA986A9, [0xAC8394]=0xA986A9, [0xAC8B94]=0xAA8DAA, [0xACBDC5]=0xBAC7AA, + [0xACC5C5]=0xBAC7AA, [0xACCDCD]=0xBECFAA, [0xACEEE6]=0xD1F3A7, [0xB44A52]=0xAC52AC, + [0xB45252]=0xAD59AD, [0xB45A5A]=0xAD61AD, [0xB45A62]=0xAD61AD, [0xB47B8B]=0xB07FB0, + [0xB4838B]=0xB087B0, [0xB48394]=0xB087B0, [0xB48B94]=0xB18EB1, [0xB48B9C]=0xB18EB1, + [0xB4949C]=0xB296B2, [0xB4C5CD]=0xC2CFB2, [0xB4EEE6]=0xD4F2B0, [0xB4F6EE]=0xD9FBAF, + [0xBD525A]=0xB55AB5, [0xBD5A62]=0xB661B6, [0xBD6262]=0xB669B6, [0xBD626A]=0xB669B6, + [0xBD6A6A]=0xB770B7, [0xBD838B]=0xB987B9, [0xBD8394]=0xB987B9, [0xBD8B94]=0xB98FB9, + [0xBD8B9C]=0xB98FB9, [0xBD949C]=0xBA97BA, [0xBD94A4]=0xBA97BA, [0xBDD5D5]=0xCAD7BB, + [0xBDEEE6]=0xD8F2B9, [0xBDEEEE]=0xD8F2B9, [0xBDF6EE]=0xDDFAB9, [0xC55A62]=0xBD62BD, + [0xC5626A]=0xBE69BE, [0xC56A6A]=0xBE71BE, [0xC56A73]=0xBE71BE, [0xC57373]=0xBF79BF, + [0xC5737B]=0xBF79BF, [0xC58B9C]=0xC18FC1, [0xC5949C]=0xC198C1, [0xC594A4]=0xC198C1, + [0xC59CA4]=0xC29FC2, [0xC5CDD5]=0xCED6C4, [0xC5D5D5]=0xCED6C4, [0xC5D5DE]=0xD3E0C3, + [0xC5DEDE]=0xD3E0C3, [0xC5EEEE]=0xDCF1C2, [0xC5F6EE]=0xE0FAC1, [0xC5F6F6]=0xE0FAC1, + [0xC5FFF6]=0xE5FFC5, [0xCD6A73]=0xC671C6, [0xCD7373]=0xC67AC6, [0xCD737B]=0xC67AC6, + [0xCD7B7B]=0xC781C7, [0xCD7B83]=0xC781C7, [0xCD8383]=0xC789C7, [0xCD838B]=0xC789C7, + [0xCD949C]=0xC998C9, [0xCD94A4]=0xC998C9, [0xCD9CA4]=0xC9A0C9, [0xCD9CAC]=0xC9A0C9, + [0xCDA4AC]=0xCAA7CA, [0xCDD5DE]=0xD6DFCC, [0xCDDEDE]=0xD6DFCC, [0xCDF6EE]=0xE4F9CA, + [0xCDF6F6]=0xE4F9CA, [0xCDFFF6]=0xE8FFCD, [0xD5737B]=0xCE7ACE, [0xD57B83]=0xCE82CE, + [0xD58383]=0xCF89CF, [0xD5838B]=0xCF89CF, [0xD58B8B]=0xCF91CF, [0xD58B94]=0xCF91CF, + [0xD58B9C]=0xCF91CF, [0xD59494]=0xD099D0, [0xD5949C]=0xD099D0, [0xD59CA4]=0xD1A0D1, + [0xD59CAC]=0xD1A0D1, [0xD5A4AC]=0xD1A8D1, [0xD5A4B4]=0xD1A8D1, [0xD5ACB4]=0xD2AFD2, + [0xD5DEE6]=0xDEE7D4, [0xD5E6E6]=0xDEE7D4, [0xD5FFF6]=0xECFFD5, [0xD5FFFF]=0xECFFD5, + [0xDE737B]=0xD67BD6, [0xDE8383]=0xD78AD7, [0xDE838B]=0xD78AD7, [0xDE8394]=0xD78AD7, + [0xDE8B8B]=0xD891D8, [0xDE8B94]=0xD891D8, [0xDE8B9C]=0xD891D8, [0xDE9494]=0xD89AD8, + [0xDE949C]=0xD89AD8, [0xDE94A4]=0xD89AD8, [0xDE9C9C]=0xD9A1D9, [0xDE9CA4]=0xD9A1D9, + [0xDEA4AC]=0xDAA8DA, [0xDEA4B4]=0xDAA8DA, [0xDEACB4]=0xDAB0DA, [0xDEACBD]=0xDAB0DA, + [0xDEB4BD]=0xDBB7DB, [0xDEF6F6]=0xEBF8DC, [0xDEFFF6]=0xF0FFDE, [0xDEFFFF]=0xF0FFDE, + [0xE67B7B]=0xDE83DE, [0xE6838B]=0xDF8ADF, [0xE68B94]=0xDF92DF, [0xE68B9C]=0xDF92DF, + [0xE69494]=0xE09AE0, [0xE6949C]=0xE09AE0, [0xE69C9C]=0xE0A2E0, [0xE69CA4]=0xE0A2E0, + [0xE69CAC]=0xE0A2E0, [0xE6A4A4]=0xE1A9E1, [0xE6A4AC]=0xE1A9E1, [0xE6A4B4]=0xE1A9E1, + [0xE6ACAC]=0xE2B0E2, [0xE6ACB4]=0xE2B0E2, [0xE6ACBD]=0xE2B0E2, [0xE6B4B4]=0xE2B8E2, + [0xE6B4BD]=0xE2B8E2, [0xE6B4C5]=0xE2B8E2, [0xE6BDC5]=0xE3C0E3, [0xE6BDCD]=0xE3C0E3, + [0xE6E6EE]=0xEAEFE5, [0xE6EEEE]=0xEAEFE5, [0xE6F6F6]=0xEFF7E5, [0xE6FFFF]=0xF4FFE6, + [0xEE8B8B]=0xE792E7, [0xEE9494]=0xE79BE7, [0xEE949C]=0xE79BE7, [0xEE9C9C]=0xE8A2E8, + [0xEE9CA4]=0xE8A2E8, [0xEEA4A4]=0xE8AAE8, [0xEEA4AC]=0xE8AAE8, [0xEEA4B4]=0xE8AAE8, + [0xEEACAC]=0xE9B1E9, [0xEEACB4]=0xE9B1E9, [0xEEACBD]=0xE9B1E9, [0xEEB4BD]=0xEAB8EA, + [0xEEB4C5]=0xEAB8EA, [0xEEBDC5]=0xEAC1EA, [0xEEBDCD]=0xEAC1EA, [0xEEC5D5]=0xEBC8EB, + [0xEEEEF6]=0xF2F7ED, [0xEEF6F6]=0xF2F7ED, [0xEEFFFF]=0xF7FFEE, [0xF68383]=0xED8CED, + [0xF68B94]=0xEE93EE, [0xF69494]=0xEF9BEF, [0xF69C9C]=0xEFA3EF, [0xF69CA4]=0xEFA3EF, + [0xF6A4A4]=0xF0AAF0, [0xF6A4AC]=0xF0AAF0, [0xF6ACAC]=0xF0B2F0, [0xF6B4B4]=0xF1B9F1, + [0xF6B4BD]=0xF1B9F1, [0xF6B4C5]=0xF1B9F1, [0xF6BDC5]=0xF2C1F2, [0xF6BDCD]=0xF2C1F2, + [0xF6C5CD]=0xF2C9F2, [0xF6C5D5]=0xF2C9F2, [0xF6D5DE]=0xF4D7F4, [0xF6FFFF]=0xFBFFF6, + [0xFF8383]=0xF68CF6, [0xFF8B8B]=0xF694F6, [0xFF9494]=0xF79CF7, [0xFF949C]=0xF79CF7, + [0xFF9C9C]=0xF8A3F8, [0xFF9CA4]=0xF8A3F8, [0xFFA4A4]=0xF8ABF8, [0xFFA4AC]=0xF8ABF8, + [0xFFACAC]=0xF9B2F9, [0xFFACB4]=0xF9B2F9, [0xFFB4B4]=0xF9BAF9, [0xFFB4BD]=0xF9BAF9, + [0xFFBDBD]=0xFAC2FA, [0xFFBDC5]=0xFAC2FA, [0xFFBDCD]=0xFAC2FA, [0xFFC5C5]=0xFBC9FB, + [0xFFC5CD]=0xFBC9FB, [0xFFC5D5]=0xFBC9FB, [0xFFCDD5]=0xFBD1FB, [0xFFCDDE]=0xFBD1FB, + [0xFFD5DE]=0xFCD8FC, [0xFFD5E6]=0xFCD8FC, [0xFFDEDE]=0xFDE0FD, [0xFFDEE6]=0xFDE0FD, + [0xFFE6E6]=0xFDE8FD, [0xFFE6EE]=0xFDE8FD, + }, + }, + [40] = { + name = "wigglytuff", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFCDDE, + -- Stadium ships a real alternate texture for this one; 409 + -- colours move, every other colour stays exactly as it was. + lut = { + [0x080008]=0x070107, [0x082018]=0x152206, [0x082920]=0x1A2B06, [0x083931]=0x233D04, + [0x100808]=0x0F090F, [0x103931]=0x273C0D, [0x104139]=0x2B450C, [0x104A39]=0x304E0C, + [0x183939]=0x2A3B16, [0x184139]=0x2F4415, [0x184A39]=0x344E14, [0x184A41]=0x344E14, + [0x18524A]=0x385614, [0x185A4A]=0x3D5F13, [0x204141]=0x32431E, [0x204A41]=0x374D1D, + [0x204A4A]=0x374D1D, [0x20524A]=0x3C561C, [0x205A52]=0x405E1C, [0x206252]=0x45671B, + [0x20625A]=0x45671B, [0x294A4A]=0x3B4C27, [0x29524A]=0x405526, [0x295252]=0x405526, + [0x295A52]=0x445E25, [0x295A5A]=0x445E25, [0x296252]=0x496625, [0x29625A]=0x496625, + [0x296A5A]=0x4D6F24, [0x29736A]=0x527923, [0x31524A]=0x43542F, [0x315252]=0x43542F, + [0x315A52]=0x485D2E, [0x315A5A]=0x485D2E, [0x31625A]=0x4C662D, [0x316A62]=0x516E2D, + [0x317362]=0x56782C, [0x31736A]=0x56782C, [0x317B6A]=0x5A812B, [0x317B73]=0x5A812B, + [0x395252]=0x475437, [0x395A52]=0x4B5C37, [0x395A5A]=0x4B5C37, [0x395A62]=0x506536, + [0x39625A]=0x506536, [0x396262]=0x506536, [0x396A62]=0x546E35, [0x39736A]=0x597735, + [0x397B6A]=0x5E8034, [0x397B73]=0x5E8034, [0x398373]=0x628933, [0x398B7B]=0x679133, + [0x415A5A]=0x4F5C3F, [0x416262]=0x53643F, [0x416A6A]=0x586D3E, [0x41736A]=0x5D773D, + [0x417373]=0x5D773D, [0x417B6A]=0x617F3D, [0x417B73]=0x617F3D, [0x418373]=0x66883C, + [0x41837B]=0x66883C, [0x418B7B]=0x6A913B, [0x418B83]=0x6A913B, [0x419483]=0x6F9A3B, + [0x41A494]=0x78AB3A, [0x4A3139]=0x483348, [0x4A6A6A]=0x5C6C48, [0x4A6A73]=0x617647, + [0x4A7373]=0x617647, [0x4A7B73]=0x657F46, [0x4A837B]=0x6A8746, [0x4A8B83]=0x6E9045, + [0x4A9483]=0x739A44, [0x4A948B]=0x739A44, [0x4A9C8B]=0x78A244, [0x4AAC9C]=0x81B343, + [0x523139]=0x503350, [0x523141]=0x503350, [0x523941]=0x503B50, [0x527373]=0x647550, + [0x52737B]=0x697E4F, [0x527B7B]=0x697E4F, [0x528383]=0x6D874E, [0x52948B]=0x77994D, + [0x529C8B]=0x7BA24C, [0x529C94]=0x7BA24C, [0x52A494]=0x80AA4C, [0x52A49C]=0x80AA4C, + [0x52AC9C]=0x84B34B, [0x5A3941]=0x583B58, [0x5A394A]=0x583B58, [0x5A414A]=0x584358, + [0x5A4152]=0x584358, [0x5A737B]=0x6C7D58, [0x5A7B7B]=0x6C7D58, [0x5A7B83]=0x718657, + [0x5A8383]=0x718657, [0x5A948B]=0x7A9856, [0x5A9C94]=0x7FA155, [0x5AA494]=0x83AA54, + [0x5AA49C]=0x83AA54, [0x5AAC9C]=0x88B254, [0x5AACA4]=0x88B254, [0x62394A]=0x5F3C5F, + [0x62414A]=0x604360, [0x62A49C]=0x87A95D, [0x62AC9C]=0x8BB25C, [0x62B4A4]=0x90BA5C, + [0x62B4AC]=0x90BA5C, [0x6A414A]=0x674467, [0x6A948B]=0x819767, [0x6AB4A4]=0x93BA64, + [0x6AB4AC]=0x93BA64, [0x6ABDAC]=0x98C364, [0x6ABDB4]=0x98C364, [0x6AC5B4]=0x9DCC63, + [0x73525A]=0x715471, [0x73C5B4]=0xA1CB6D, [0x73C5BD]=0xA1CB6D, [0x7B4A5A]=0x774E77, + [0x7B525A]=0x785578, [0x7B5A62]=0x795C79, [0x7B9CA4]=0x92A778, [0x7BB4AC]=0x9BB877, + [0x7BBDB4]=0xA0C276, [0x7BC5B4]=0xA4CB75, [0x7BC5BD]=0xA4CB75, [0x7BCDBD]=0xA9D375, + [0x7BCDC5]=0xA9D375, [0x7BD5C5]=0xADDC74, [0x83525A]=0x7F567F, [0x835262]=0x7F567F, + [0x835A62]=0x805D80, [0x835A6A]=0x805D80, [0x839CA4]=0x95A681, [0x83ACA4]=0x9AAF80, + [0x83C5BD]=0xA8CA7E, [0x83CDC5]=0xACD37D, [0x83D5C5]=0xB1DB7D, [0x83D5CD]=0xB1DB7D, + [0x8B5262]=0x875687, [0x8B5A62]=0x875E87, [0x8B5A6A]=0x875E87, [0x8B626A]=0x886588, + [0x8BA4AC]=0x9DAE89, [0x8BB4AC]=0xA2B788, [0x8BB4B4]=0xA2B788, [0x8BCDC5]=0xB0D286, + [0x8BD5CD]=0xB4DB85, [0x945A6A]=0x905E90, [0x94626A]=0x906690, [0x946273]=0x906690, + [0x946A73]=0x916D91, [0x946A7B]=0x916D91, [0x94ACB4]=0xA6B692, [0x94B4AC]=0xA6B692, + [0x94B4B4]=0xA6B692, [0x94BDB4]=0xABC091, [0x94D5CD]=0xB8DA8F, [0x94DED5]=0xBDE48E, + [0x9C6273]=0x986698, [0x9C6A73]=0x986E98, [0x9C6A7B]=0x986E98, [0x9C737B]=0x997699, + [0x9C7383]=0x997699, [0x9CB4BD]=0xAEBF9A, [0x9CBDBD]=0xAEBF9A, [0x9CC5BD]=0xB3C899, + [0x9CC5C5]=0xB3C899, [0x9CDED5]=0xC1E397, [0x9CE6DE]=0xC5EC96, [0xA44141]=0x9D489D, + [0xA46A7B]=0xA06EA0, [0xA4737B]=0xA077A0, [0xA47383]=0xA077A0, [0xA47B83]=0xA17EA1, + [0xA47B8B]=0xA17EA1, [0xA4838B]=0xA285A2, [0xA4BDBD]=0xB2BFA2, [0xA4E6DE]=0xC9EB9F, + [0xA4EEDE]=0xCDF49E, [0xAC4A4A]=0xA551A5, [0xAC4A52]=0xA551A5, [0xAC525A]=0xA559A5, + [0xAC626A]=0xA668A6, [0xAC7383]=0xA877A8, [0xAC838B]=0xA986A9, [0xAC8394]=0xA986A9, + [0xAC8B94]=0xAA8DAA, [0xACBDC5]=0xBAC7AA, [0xACC5C5]=0xBAC7AA, [0xACCDCD]=0xBECFAA, + [0xACEEE6]=0xD1F3A7, [0xB44A52]=0xAC52AC, [0xB45252]=0xAD59AD, [0xB45A5A]=0xAD61AD, + [0xB45A62]=0xAD61AD, [0xB46A73]=0xAE70AE, [0xB46A7B]=0xAE70AE, [0xB47373]=0xAF78AF, + [0xB4737B]=0xAF78AF, [0xB47B8B]=0xB07FB0, [0xB4838B]=0xB087B0, [0xB48394]=0xB087B0, + [0xB48B94]=0xB18EB1, [0xB48B9C]=0xB18EB1, [0xB4949C]=0xB296B2, [0xB4EEE6]=0xD4F2B0, + [0xB4F6EE]=0xD9FBAF, [0xBD525A]=0xB55AB5, [0xBD5A62]=0xB661B6, [0xBD6262]=0xB669B6, + [0xBD626A]=0xB669B6, [0xBD6A6A]=0xB770B7, [0xBD737B]=0xB779B7, [0xBD7B7B]=0xB880B8, + [0xBD7B83]=0xB880B8, [0xBD838B]=0xB987B9, [0xBD8394]=0xB987B9, [0xBD8B94]=0xB98FB9, + [0xBD949C]=0xBA97BA, [0xBD94A4]=0xBA97BA, [0xBDD5D5]=0xCAD7BB, [0xBDEEEE]=0xD8F2B9, + [0xBDF6EE]=0xDDFAB9, [0xC55A62]=0xBD62BD, [0xC5626A]=0xBE69BE, [0xC56A6A]=0xBE71BE, + [0xC56A73]=0xBE71BE, [0xC57373]=0xBF79BF, [0xC5737B]=0xBF79BF, [0xC57B7B]=0xBF81BF, + [0xC57B83]=0xBF81BF, [0xC58383]=0xC088C0, [0xC5838B]=0xC088C0, [0xC58B9C]=0xC18FC1, + [0xC5949C]=0xC198C1, [0xC594A4]=0xC198C1, [0xC59CA4]=0xC29FC2, [0xC5ACB4]=0xC3AEC3, + [0xC5CDD5]=0xCED6C4, [0xC5D5D5]=0xCED6C4, [0xC5D5DE]=0xD3E0C3, [0xC5DEDE]=0xD3E0C3, + [0xC5F6EE]=0xE0FAC1, [0xC5F6F6]=0xE0FAC1, [0xC5FFF6]=0xE5FFC5, [0xCD6A73]=0xC671C6, + [0xCD7373]=0xC67AC6, [0xCD737B]=0xC67AC6, [0xCD7B7B]=0xC781C7, [0xCD7B83]=0xC781C7, + [0xCD8383]=0xC789C7, [0xCD838B]=0xC789C7, [0xCD8B8B]=0xC890C8, [0xCD8B94]=0xC890C8, + [0xCD949C]=0xC998C9, [0xCD94A4]=0xC998C9, [0xCD9CA4]=0xC9A0C9, [0xCD9CAC]=0xC9A0C9, + [0xCDA4AC]=0xCAA7CA, [0xCDB4BD]=0xCBB6CB, [0xCDD5DE]=0xD6DFCC, [0xCDDEDE]=0xD6DFCC, + [0xCDF6EE]=0xE4F9CA, [0xCDF6F6]=0xE4F9CA, [0xCDFFF6]=0xE8FFCD, [0xD5737B]=0xCE7ACE, + [0xD57B83]=0xCE82CE, [0xD58383]=0xCF89CF, [0xD5838B]=0xCF89CF, [0xD58B8B]=0xCF91CF, + [0xD58B94]=0xCF91CF, [0xD58B9C]=0xCF91CF, [0xD59494]=0xD099D0, [0xD5949C]=0xD099D0, + [0xD594A4]=0xD099D0, [0xD59C9C]=0xD1A0D1, [0xD59CA4]=0xD1A0D1, [0xD59CAC]=0xD1A0D1, + [0xD5A4AC]=0xD1A8D1, [0xD5A4B4]=0xD1A8D1, [0xD5ACB4]=0xD2AFD2, [0xD5B4BD]=0xD3B6D3, + [0xD5BDBD]=0xD3BFD3, [0xD5BDC5]=0xD3BFD3, [0xD5DEE6]=0xDEE7D4, [0xD5E6E6]=0xDEE7D4, + [0xD5FFFF]=0xECFFD5, [0xDE737B]=0xD67BD6, [0xDE8383]=0xD78AD7, [0xDE838B]=0xD78AD7, + [0xDE8394]=0xD78AD7, [0xDE8B8B]=0xD891D8, [0xDE8B94]=0xD891D8, [0xDE8B9C]=0xD891D8, + [0xDE9494]=0xD89AD8, [0xDE949C]=0xD89AD8, [0xDE94A4]=0xD89AD8, [0xDE9C9C]=0xD9A1D9, + [0xDE9CA4]=0xD9A1D9, [0xDEA4A4]=0xDAA8DA, [0xDEA4AC]=0xDAA8DA, [0xDEA4B4]=0xDAA8DA, + [0xDEACB4]=0xDAB0DA, [0xDEACBD]=0xDAB0DA, [0xDEB4BD]=0xDBB7DB, [0xDEBDC5]=0xDCBFDC, + [0xDEC5CD]=0xDCC7DC, [0xDECDCD]=0xDDCEDD, [0xDEF6F6]=0xEBF8DC, [0xDEFFF6]=0xF0FFDE, + [0xDEFFFF]=0xF0FFDE, [0xE67B7B]=0xDE83DE, [0xE6838B]=0xDF8ADF, [0xE68B94]=0xDF92DF, + [0xE68B9C]=0xDF92DF, [0xE69494]=0xE09AE0, [0xE6949C]=0xE09AE0, [0xE69C9C]=0xE0A2E0, + [0xE69CA4]=0xE0A2E0, [0xE69CAC]=0xE0A2E0, [0xE6A4A4]=0xE1A9E1, [0xE6A4AC]=0xE1A9E1, + [0xE6A4B4]=0xE1A9E1, [0xE6ACAC]=0xE2B0E2, [0xE6ACB4]=0xE2B0E2, [0xE6ACBD]=0xE2B0E2, + [0xE6B4B4]=0xE2B8E2, [0xE6B4BD]=0xE2B8E2, [0xE6B4C5]=0xE2B8E2, [0xE6BDC5]=0xE3C0E3, + [0xE6BDCD]=0xE3C0E3, [0xE6C5CD]=0xE4C7E4, [0xE6E6EE]=0xEAEFE5, [0xE6EEEE]=0xEAEFE5, + [0xE6F6F6]=0xEFF7E5, [0xE6FFFF]=0xF4FFE6, [0xEE8B8B]=0xE792E7, [0xEE9494]=0xE79BE7, + [0xEE949C]=0xE79BE7, [0xEE9C9C]=0xE8A2E8, [0xEE9CA4]=0xE8A2E8, [0xEEA4A4]=0xE8AAE8, + [0xEEA4AC]=0xE8AAE8, [0xEEA4B4]=0xE8AAE8, [0xEEACAC]=0xE9B1E9, [0xEEACB4]=0xE9B1E9, + [0xEEACBD]=0xE9B1E9, [0xEEB4BD]=0xEAB8EA, [0xEEB4C5]=0xEAB8EA, [0xEEBDC5]=0xEAC1EA, + [0xEEBDCD]=0xEAC1EA, [0xEEC5CD]=0xEBC8EB, [0xEEC5D5]=0xEBC8EB, [0xEECDD5]=0xECCFEC, + [0xEED5D5]=0xECD7EC, [0xEED5DE]=0xECD7EC, [0xEEEEF6]=0xF2F7ED, [0xEEF6F6]=0xF2F7ED, + [0xEEFFFF]=0xF7FFEE, [0xF68383]=0xED8CED, [0xF68B94]=0xEE93EE, [0xF69C9C]=0xEFA3EF, + [0xF69CA4]=0xEFA3EF, [0xF6A4A4]=0xF0AAF0, [0xF6A4AC]=0xF0AAF0, [0xF6ACAC]=0xF0B2F0, + [0xF6B4B4]=0xF1B9F1, [0xF6B4BD]=0xF1B9F1, [0xF6B4C5]=0xF1B9F1, [0xF6BDC5]=0xF2C1F2, + [0xF6BDCD]=0xF2C1F2, [0xF6C5CD]=0xF2C9F2, [0xF6C5D5]=0xF2C9F2, [0xF6CDD5]=0xF3D0F3, + [0xF6D5D5]=0xF4D7F4, [0xF6D5DE]=0xF4D7F4, [0xF6FFFF]=0xFBFFF6, [0xFF8383]=0xF68CF6, + [0xFF8B8B]=0xF694F6, [0xFF9494]=0xF79CF7, [0xFF949C]=0xF79CF7, [0xFF9C9C]=0xF8A3F8, + [0xFF9CA4]=0xF8A3F8, [0xFFA4A4]=0xF8ABF8, [0xFFA4AC]=0xF8ABF8, [0xFFACAC]=0xF9B2F9, + [0xFFACB4]=0xF9B2F9, [0xFFB4B4]=0xF9BAF9, [0xFFBDBD]=0xFAC2FA, [0xFFBDCD]=0xFAC2FA, + [0xFFC5C5]=0xFBC9FB, [0xFFC5CD]=0xFBC9FB, [0xFFC5D5]=0xFBC9FB, [0xFFCDCD]=0xFBD1FB, + [0xFFCDD5]=0xFBD1FB, [0xFFCDDE]=0xFBD1FB, [0xFFD5D5]=0xFCD8FC, [0xFFD5DE]=0xFCD8FC, + [0xFFD5E6]=0xFCD8FC, [0xFFDEDE]=0xFDE0FD, [0xFFDEE6]=0xFDE0FD, [0xFFE6E6]=0xFDE8FD, + [0xFFE6EE]=0xFDE8FD, [0xFFEEEE]=0xFEEFFE, [0xFFEEF6]=0xFEEFFE, [0xFFF6F6]=0xFEF7FE, + [0xFFF6FF]=0xFEF7FE, + }, + }, + [41] = { + name = "zubat", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x73DEF6, + slide = { h = -110, l = -1, s = -2 }, + }, + [42] = { + name = "golbat", + hueRange = { min = -25, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x29ACE6, + slide = { h = -63, l = -6, s = -4 }, + }, + [43] = { + name = "oddish", + hueRange = { min = -40, max = 70 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x626A83, + slide = { h = -50, l = 2, s = 3 }, + }, + [44] = { + name = "gloom", + hueRange = { min = -40, max = 65 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x626A83, + slide = { h = 30, l = 3, s = 1 }, + }, + [45] = { + name = "vileplume", + hueRange = { min = -25, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x626A83, + slide = { h = 50, l = 3, s = 1 }, + }, + [46] = { + name = "paras", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xC5E6FF, + slide = { h = 25, l = -3, s = 3 }, + }, + [47] = { + name = "parasect", + hueRange = { min = -20, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEE5231, + slide = { h = 40, l = -4, s = 6 }, + }, + [48] = { + name = "venonat", + hueRange = { min = -60, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x4A3952, + slide = { h = -90, l = 0, s = 0 }, + }, + [49] = { + name = "venomoth", + hueRange = { min = -60, max = 100 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6C5E6, + slide = { h = -90, l = 0, s = 1 }, + }, + [50] = { + name = "diglett", + hueRange = { min = -20, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD6239, + slide = { h = -50, l = 1, s = -2 }, + }, + [51] = { + name = "dugtrio", + hueRange = { min = -20, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD6239, + slide = { h = -50, l = 1, s = -2 }, + }, + [52] = { + name = "meowth", + hueRange = { min = -40, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6EEAC, + slide = { h = -55, l = 2, s = 0 }, + }, + [53] = { + name = "persian", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFE6BD, + slide = { h = -30, l = 0, s = -2 }, + }, + [54] = { + name = "psyduck", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFDE29, + slide = { h = -142, l = 2, s = -2 }, + }, + [55] = { + name = "golduck", + hueRange = { min = -60, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x8B9CAC, + slide = { h = 20, l = 0, s = 3 }, + }, + [56] = { + name = "mankey", + hueRange = { min = -35, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFEEAC, + slide = { h = 105, l = -1, s = -3 }, + }, + [57] = { + name = "primeape", + hueRange = { min = -35, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFD59C, + slide = { h = -50, l = 1, s = -3 }, + }, + [58] = { + name = "growlithe", + hueRange = { min = -35, max = 10 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEEB420, + slide = { h = 20, l = 0, s = 2 }, + }, + [59] = { + name = "arcanine", + hueRange = { min = -30, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF69429, + slide = { h = 25, l = 1, s = -3 }, + }, + [60] = { + name = "poliwag", + hueRange = { min = -50, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6A7BA4, + slide = { h = -20, l = 0, s = 3 }, + }, + [61] = { + name = "poliwhirl", + hueRange = { min = -50, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6A7BA4, + slide = { h = 20, l = -2, s = 2 }, + }, + [62] = { + name = "poliwrath", + hueRange = { min = -50, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6A7BA4, + slide = { h = -58, l = -2, s = 3 }, + }, + [63] = { + name = "abra", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xD5A420, + slide = { h = 20, l = 1, s = -1 }, + }, + [64] = { + name = "kadabra", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEECD5A, + slide = { h = 10, l = 1, s = 1 }, + }, + [65] = { + name = "alakazam", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEECD5A, + slide = { h = 20, l = -2, s = 1 }, + }, + [66] = { + name = "machop", + hueRange = { min = -40, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xACD5BD, + slide = { h = 100, l = 0, s = 0 }, + }, + [67] = { + name = "machoke", + hueRange = { min = -70, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x949CBD, + slide = { h = -120, l = 1, s = -2 }, + }, + [68] = { + name = "machamp", + hueRange = { min = -70, max = 80 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xACD5C5, + slide = { h = 100, l = 0, s = -2 }, + }, + [69] = { + name = "bellsprout", + hueRange = { min = -15, max = 15 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFFF18, + slide = { h = -25, l = 0, s = -3 }, + }, + [70] = { + name = "weepinbell", + hueRange = { min = -25, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF8F860, + slide = { h = -75, l = -1, s = -3 }, + }, + [71] = { + name = "victreebel", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFFF62, + slide = { h = 40, l = -2, s = -3 }, + }, + [72] = { + name = "tentacool", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x4AC5C5, + slide = { h = 50, l = 2, s = 2 }, + }, + [73] = { + name = "tentacruel", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x4AC5C5, + slide = { h = 50, l = 2, s = 2 }, + }, + [74] = { + name = "geodude", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x73735A, + slide = { h = -70, l = 0, s = 0 }, + }, + [75] = { + name = "graveler", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x525239, + slide = { h = -55, l = 0, s = 0 }, + }, + [76] = { + name = "golem", + hueRange = { min = -50, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x836A52, + slide = { h = -55, l = 0, s = -2 }, + }, + [77] = { + name = "ponyta", + hueRange = { min = -25, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFFF83, + slide = { h = 40, l = 2, s = -1 }, + }, + [78] = { + name = "rapidash", + hueRange = { min = -25, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFFF83, + slide = { h = 40, l = 2, s = -1 }, + }, + [79] = { + name = "slowpoke", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFF8B94, + slide = { h = -90, l = -6, s = -3 }, + }, + [80] = { + name = "slowbro", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA4ACA4, + slide = { h = -75, l = -4, s = -2 }, + }, + [81] = { + name = "magnemite", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5A5A5A, + slide = { h = -30, l = 2, s = -4 }, + }, + [82] = { + name = "magneton", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5A5A5A, + slide = { h = -30, l = 2, s = -4 }, + }, + [83] = { + name = "farfetchd", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xB4946A, + slide = { h = 15, l = 1, s = 2 }, + }, + [84] = { + name = "doduo", + hueRange = { min = -10, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD6200, + slide = { h = 31, l = 1, s = 2 }, + }, + [85] = { + name = "dodrio", + hueRange = { min = -10, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xB45A00, + slide = { h = 31, l = 1, s = 2 }, + }, + [86] = { + name = "seel", + hueRange = { min = -40, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE67373, + slide = { h = -180, l = 3, s = -4 }, + }, + [87] = { + name = "dewgong", + hueRange = { min = -10, max = 10 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDEEEDE, + slide = { h = -180, l = 3, s = -4 }, + }, + [88] = { + name = "grimer", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5A4A5A, + slide = { h = 125, l = -1, s = 1 }, + }, + [89] = { + name = "muk", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5A4A5A, + slide = { h = -115, l = 0, s = 1 }, + }, + [90] = { + name = "shellder", + hueRange = { min = -70, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x8B73C5, + slide = { h = 120, l = 0, s = 0 }, + }, + [91] = { + name = "cloyster", + hueRange = { min = -70, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x29104A, + slide = { h = 140, l = 0, s = 0 }, + }, + [92] = { + name = "gastly", + hueRange = { min = -80, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x201029, + slide = { h = -124, l = 0, s = 0 }, + }, + [93] = { + name = "haunter", + hueRange = { min = -70, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6A418B, + slide = { h = -50, l = -2, s = -2 }, + }, + [94] = { + name = "gengar", + hueRange = { min = -90, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5A3962, + slide = { h = -55, l = -2, s = -1 }, + }, + [95] = { + name = "onix", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA4A4BD, + slide = { h = -171, l = -3, s = 0 }, + }, + [96] = { + name = "drowzee", + hueRange = { min = -10, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFBD00, + slide = { h = -110, l = 4, s = -4 }, + }, + [97] = { + name = "hypno", + hueRange = { min = -15, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE6BD18, + slide = { h = -80, l = 1, s = -3 }, + }, + [98] = { + name = "krabby", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF63100, + slide = { h = 40, l = -1, s = 0 }, + }, + [99] = { + name = "kingler", + hueRange = { min = -20, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD6A31, + slide = { h = 35, l = -1, s = 2 }, + }, + [100] = { + name = "voltorb", + hueRange = { min = -70, max = 10 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x8B3939, + slide = { h = -95, l = 0, s = 0 }, + }, + [101] = { + name = "electrode", + hueRange = { min = -70, max = 10 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD4A4A, + slide = { h = -95, l = 0, s = 0 }, + }, + [102] = { + name = "exeggcute", + hueRange = { min = -50, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6B4BD, + slide = { h = 70, l = 0, s = 0 }, + }, + [103] = { + name = "exeggutor", + hueRange = { min = -30, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFEE83, + slide = { h = -60, l = 2, s = -3 }, + }, + [104] = { + name = "cubone", + hueRange = { min = -25, max = 5 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDEAC39, + slide = { h = 160, l = 2, s = -5 }, + }, + [105] = { + name = "marowak", + hueRange = { min = -25, max = 5 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDED5C5, + slide = { h = 160, l = 2, s = -5 }, + }, + [106] = { + name = "hitmonlee", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x94524A, + slide = { h = 70, l = 0, s = -2 }, + }, + [107] = { + name = "hitmonchan", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA4836A, + slide = { h = -145, l = 1, s = -1 }, + }, + [108] = { + name = "lickitung", + hueRange = { min = -50, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6A4A4, + slide = { h = 60, l = -1, s = 1 }, + }, + [109] = { + name = "koffing", + hueRange = { min = -60, max = 90 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x73418B, + slide = { h = -80, l = -1, s = 3 }, + }, + [110] = { + name = "weezing", + hueRange = { min = -60, max = 90 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x836A83, + slide = { h = -90, l = 0, s = 1 }, + }, + [111] = { + name = "rhyhorn", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA4ACBD, + slide = { h = 150, l = -1, s = 1 }, + }, + [112] = { + name = "rhydon", + hueRange = { min = -180, max = 180 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA4A4AC, + slide = { h = 58, l = 1, s = 1 }, + }, + [113] = { + name = "chansey", + hueRange = { min = -50, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFD5E6, + slide = { h = 80, l = 0, s = 0 }, + }, + [114] = { + name = "tangela", + hueRange = { min = -40, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEE5A73, + slide = { h = -120, l = 0, s = 0 }, + }, + [115] = { + name = "kangaskhan", + hueRange = { min = -30, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x836A4A, + slide = { h = -176, l = 0, s = -2 }, + }, + [116] = { + name = "horsea", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x83B4DE, + slide = { h = -70, l = 0, s = -1 }, + }, + [117] = { + name = "seadra", + hueRange = { min = -30, max = 35 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x5AB4DE, + slide = { h = -65, l = 2, s = 1 }, + }, + [118] = { + name = "goldeen", + hueRange = { min = -60, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6F6D5, + slide = { h = 50, l = 0, s = 3 }, + }, + [119] = { + name = "seaking", + hueRange = { min = -50, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xB43918, + slide = { h = 40, l = 1, s = 3 }, + }, + [120] = { + name = "staryu", + hueRange = { min = -10, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE14254, + slide = { h = -176, l = 0, s = 0 }, + }, + [121] = { + name = "starmie", + hueRange = { min = -40, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFF3888, + slide = { h = -110, l = 0, s = 0 }, + }, + [122] = { + name = "mr_mime", + hueRange = { min = -30, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFC5C5, + slide = { h = 30, l = -2, s = 2 }, + }, + [123] = { + name = "scyther", + hueRange = { min = -30, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x83C56A, + slide = { h = -110, l = 0, s = 0 }, + }, + [124] = { + name = "jynx", + hueRange = { min = -10, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x525A73, + slide = { h = 30, l = 0, s = 3 }, + }, + [125] = { + name = "electabuzz", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFEE00, + slide = { h = -40, l = 2, s = -2 }, + }, + [126] = { + name = "magmar", + hueRange = { min = -15, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEE3918, + slide = { h = -60, l = 1, s = -2 }, + }, + [127] = { + name = "pinsir", + hueRange = { min = -60, max = 130 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x836A62, + slide = { h = -150, l = 0, s = 0 }, + }, + [128] = { + name = "tauros", + hueRange = { min = -10, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xC59429, + slide = { h = -20, l = 4, s = -2 }, + }, + [129] = { + name = "magikarp", + hueRange = { min = -30, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE64A31, + slide = { h = 50, l = -1, s = 2 }, + }, + [130] = { + name = "gyarados", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x62BDEE, + -- Stadium ships a real alternate texture for this one; 481 + -- colours move, every other colour stays exactly as it was. + lut = { + [0x000008]=0x070000, [0x000808]=0x070000, [0x000810]=0x0D0100, [0x001010]=0x0D0100, + [0x001018]=0x140100, [0x080008]=0x070000, [0x080010]=0x0D0100, [0x080810]=0x0D0707, + [0x080818]=0x140707, [0x080820]=0x1A0807, [0x080829]=0x220807, [0x080831]=0x280907, + [0x080839]=0x2F0907, [0x081010]=0x0D0707, [0x081018]=0x140707, [0x081020]=0x1A0807, + [0x081031]=0x280907, [0x081039]=0x2F0907, [0x081041]=0x350A07, [0x081818]=0x140707, + [0x081820]=0x1A0807, [0x081829]=0x220807, [0x081841]=0x350A07, [0x08184A]=0x3D0A07, + [0x082029]=0x220807, [0x082031]=0x280907, [0x082039]=0x2F0907, [0x082041]=0x350A07, + [0x08204A]=0x3D0A07, [0x082052]=0x430B07, [0x082931]=0x280907, [0x082941]=0x350A07, + [0x082952]=0x430B07, [0x08295A]=0x4A0B07, [0x082962]=0x500B07, [0x083131]=0x280907, + [0x083141]=0x350A07, [0x083152]=0x430B07, [0x08315A]=0x4A0B07, [0x083162]=0x500B07, + [0x08394A]=0x3D0A07, [0x08395A]=0x4A0B07, [0x083962]=0x500B07, [0x08396A]=0x570C07, + [0x083973]=0x5E0C07, [0x084162]=0x500B07, [0x08416A]=0x570C07, [0x084173]=0x5E0C07, + [0x08417B]=0x650D07, [0x084A6A]=0x570C07, [0x084A73]=0x5E0C07, [0x084A7B]=0x650D07, + [0x085273]=0x5E0C07, [0x08527B]=0x650D07, [0x085283]=0x6B0D07, [0x08528B]=0x720E07, + [0x085A8B]=0x720E07, [0x085A94]=0x790E07, [0x086294]=0x790E07, [0x08629C]=0x800F07, + [0x086A9C]=0x800F07, [0x100010]=0x0D0100, [0x100018]=0x140100, [0x100810]=0x0D0707, + [0x101820]=0x1A0E0D, [0x102029]=0x220E0D, [0x102031]=0x280F0D, [0x102931]=0x280F0D, + [0x102939]=0x2F0F0D, [0x103139]=0x2F0F0D, [0x103141]=0x35100D, [0x104A5A]=0x4A110D, + [0x105273]=0x5E130D, [0x105A8B]=0x72140D, [0x10628B]=0x72140D, [0x106294]=0x79140D, + [0x106A9C]=0x80150D, [0x106AA4]=0x86150D, [0x1073A4]=0x86150D, [0x180818]=0x140707, + [0x180820]=0x1A0807, [0x181018]=0x140E0D, [0x182939]=0x2F1514, [0x183139]=0x2F1514, + [0x183141]=0x351614, [0x18314A]=0x3D1614, [0x183941]=0x351614, [0x18394A]=0x3D1614, + [0x183952]=0x431714, [0x184152]=0x431714, [0x18415A]=0x4A1714, [0x185262]=0x501814, + [0x185A7B]=0x651914, [0x186A94]=0x791A14, [0x18739C]=0x801B14, [0x1873A4]=0x861B14, + [0x1873AC]=0x8D1C14, [0x187BAC]=0x8D1C14, [0x200820]=0x1A0807, [0x200829]=0x220807, + [0x201020]=0x1A0E0D, [0x201029]=0x220E0D, [0x201831]=0x281514, [0x203941]=0x351C1A, + [0x20394A]=0x3D1D1A, [0x203952]=0x431D1A, [0x20414A]=0x3D1D1A, [0x204152]=0x431D1A, + [0x20415A]=0x4A1D1A, [0x204A5A]=0x4A1D1A, [0x204A62]=0x501E1A, [0x205262]=0x501E1A, + [0x20526A]=0x571E1A, [0x206273]=0x5E1F1A, [0x20627B]=0x651F1A, [0x206283]=0x6B201A, + [0x20739C]=0x80211A, [0x207BA4]=0x86211A, [0x207BAC]=0x8D221A, [0x207BB4]=0x94221A, + [0x2083B4]=0x94221A, [0x290829]=0x220807, [0x291029]=0x220E0D, [0x291831]=0x281514, + [0x29314A]=0x3D2322, [0x293941]=0x352322, [0x29394A]=0x3D2322, [0x294152]=0x432422, + [0x294A5A]=0x4A2422, [0x294A62]=0x502522, [0x295262]=0x502522, [0x29526A]=0x572522, + [0x295273]=0x5E2622, [0x295A6A]=0x572522, [0x295A73]=0x5E2622, [0x295A7B]=0x652622, + [0x296273]=0x5E2622, [0x29627B]=0x652622, [0x296A8B]=0x722722, [0x297394]=0x792722, + [0x29739C]=0x802822, [0x297BA4]=0x862822, [0x297BAC]=0x8D2922, [0x2983B4]=0x942922, + [0x2983BD]=0x9B2A22, [0x298BBD]=0x9B2A22, [0x311031]=0x280F0D, [0x311039]=0x2F0F0D, + [0x311839]=0x2F1514, [0x312031]=0x281B1A, [0x312039]=0x2F1C1A, [0x312941]=0x352322, + [0x313152]=0x432A28, [0x313952]=0x432A28, [0x31415A]=0x4A2A28, [0x314A5A]=0x4A2A28, + [0x314A62]=0x502B28, [0x315262]=0x502B28, [0x31526A]=0x572B28, [0x315A6A]=0x572B28, + [0x315A73]=0x5E2C28, [0x316273]=0x5E2C28, [0x31627B]=0x652C28, [0x316283]=0x6B2D28, + [0x316A7B]=0x652C28, [0x316A83]=0x6B2D28, [0x316A8B]=0x722D28, [0x31738B]=0x722D28, + [0x317394]=0x792E28, [0x31739C]=0x802E28, [0x317B9C]=0x802E28, [0x317BA4]=0x862E28, + [0x3183A4]=0x862E28, [0x3183AC]=0x8D2F28, [0x3183B4]=0x942F28, [0x318BB4]=0x942F28, + [0x318BBD]=0x9B3028, [0x318BC5]=0xA23028, [0x3194C5]=0xA23028, [0x391841]=0x351614, + [0x392039]=0x2F1C1A, [0x39395A]=0x4A312F, [0x394152]=0x43302F, [0x39525A]=0x4A312F, + [0x395262]=0x50312F, [0x39526A]=0x57312F, [0x395A62]=0x50312F, [0x395A6A]=0x57312F, + [0x396273]=0x5E322F, [0x39627B]=0x65322F, [0x396A7B]=0x65322F, [0x396A83]=0x6B332F, + [0x396A8B]=0x72332F, [0x39738B]=0x72332F, [0x397394]=0x79342F, [0x39739C]=0x80342F, + [0x397B8B]=0x72332F, [0x397B94]=0x79342F, [0x397B9C]=0x80342F, [0x397BA4]=0x86352F, + [0x3983A4]=0x86352F, [0x3983AC]=0x8D352F, [0x3983B4]=0x94352F, [0x398BB4]=0x94352F, + [0x398BBD]=0x9B362F, [0x3994BD]=0x9B362F, [0x3994C5]=0xA2362F, [0x3994CD]=0xAC342B, + [0x399CCD]=0xAC342B, [0x411841]=0x351614, [0x41184A]=0x3D1614, [0x412041]=0x351C1A, + [0x41204A]=0x3D1D1A, [0x412952]=0x432422, [0x413962]=0x50312F, [0x415A62]=0x503735, + [0x415A6A]=0x573835, [0x41626A]=0x573835, [0x416273]=0x5E3835, [0x41627B]=0x653835, + [0x416A6A]=0x573835, [0x416A83]=0x6B3935, [0x417383]=0x6B3935, [0x41738B]=0x723935, + [0x417394]=0x793A35, [0x417B8B]=0x723935, [0x417B94]=0x793A35, [0x417B9C]=0x803A35, + [0x41838B]=0x723935, [0x41839C]=0x803A35, [0x4183A4]=0x863B35, [0x4183AC]=0x8D3B35, + [0x418BA4]=0x863B35, [0x418BAC]=0x8D3B35, [0x418BB4]=0x943C35, [0x418BBD]=0x9B3C35, + [0x4194BD]=0x9B3C35, [0x4194C5]=0xA53A32, [0x419CC5]=0xA53A32, [0x419CCD]=0xAF372E, + [0x419CD5]=0xBB3329, [0x41A4D5]=0xBB3329, [0x4A204A]=0x3D1D1A, [0x4A2052]=0x431D1A, + [0x4A2952]=0x432422, [0x4A295A]=0x4A2422, [0x4A314A]=0x3D2A28, [0x4A3152]=0x432A28, + [0x4A315A]=0x4A2A28, [0x4A3962]=0x50312F, [0x4A626A]=0x573E3D, [0x4A6273]=0x5E3F3D, + [0x4A6A73]=0x5E3F3D, [0x4A7B9C]=0x80413D, [0x4A839C]=0x80413D, [0x4A83A4]=0x86423D, + [0x4A83AC]=0x8D423D, [0x4A8BA4]=0x86423D, [0x4A8BAC]=0x8D423D, [0x4A8BB4]=0x94423D, + [0x4A94AC]=0x8D423D, [0x4A94B4]=0x94423D, [0x4A94BD]=0x9E403A, [0x4A94C5]=0xA83E36, + [0x4A9CBD]=0x9E403A, [0x4A9CC5]=0xA83E36, [0x4A9CCD]=0xB33A32, [0x4AA4CD]=0xB33A32, + [0x4AA4D5]=0xBF362C, [0x4AA4DE]=0xCD3125, [0x4AACDE]=0xCD3125, [0x52205A]=0x4A1D1A, + [0x52295A]=0x4A2422, [0x522962]=0x502522, [0x52315A]=0x4A2A28, [0x523952]=0x43302F, + [0x52395A]=0x4A312F, [0x524162]=0x503735, [0x526273]=0x5E4543, [0x526A73]=0x5E4543, + [0x527383]=0x6B4643, [0x52838B]=0x724643, [0x528394]=0x794743, [0x528B9C]=0x804743, + [0x5294B4]=0x964741, [0x5294BD]=0xA1443D, [0x529CB4]=0x964741, [0x529CBD]=0xA1443D, + [0x529CC5]=0xAB4139, [0x529CCD]=0xB73D35, [0x52A4C5]=0xAB4139, [0x52A4CD]=0xB73D35, + [0x52A4D5]=0xC3392F, [0x52A4DE]=0xD13328, [0x52ACCD]=0xB73D35, [0x52ACD5]=0xC3392F, + [0x52ACDE]=0xD13328, [0x52ACE6]=0xDF2E21, [0x52B4E6]=0xDF2E21, [0x5A295A]=0x4A2422, + [0x5A2962]=0x502522, [0x5A296A]=0x572522, [0x5A315A]=0x4A2A28, [0x5A3162]=0x502B28, + [0x5A316A]=0x572B28, [0x5A6294]=0x794D4A, [0x5A6A7B]=0x654C4A, [0x5A737B]=0x654C4A, + [0x5A7383]=0x6B4C4A, [0x5A7B83]=0x6B4C4A, [0x5A838B]=0x724C4A, [0x5A839C]=0x804D4A, + [0x5A8B94]=0x794D4A, [0x5A8B9C]=0x804D4A, [0x5AA4BD]=0xA34841, [0x5AA4C5]=0xAE453D, + [0x5AA4CD]=0xBA4138, [0x5AA4D5]=0xC63C32, [0x5AACCD]=0xBA4138, [0x5AACD5]=0xC63C32, + [0x5AACDE]=0xD5362B, [0x5AB4D5]=0xC63C32, [0x5AB4DE]=0xD5362B, [0x5AB4E6]=0xDE3428, + [0x5AB4EE]=0xE83224, [0x5ABDE6]=0xDE3428, [0x5ABDEE]=0xE83224, [0x62296A]=0x572522, + [0x62316A]=0x572B28, [0x623173]=0x5E2C28, [0x627B8B]=0x725350, [0x628B8B]=0x725350, + [0x628B94]=0x795350, [0x628B9C]=0x805450, [0x6294A4]=0x88534F, [0x62A4C5]=0xB14941, + [0x62ACC5]=0xB14941, [0x62B4DE]=0xD43D32, [0x62B4E6]=0xDE3B2F, [0x62B4EE]=0xE8382B, + [0x62BDE6]=0xDE3B2F, [0x62BDEE]=0xE8382B, [0x62BDF6]=0xF33527, [0x62C5EE]=0xE8382B, + [0x62C5F6]=0xF33527, [0x6A3173]=0x5E2C28, [0x6A396A]=0x57312F, [0x6A3973]=0x5E322F, + [0x6A397B]=0x65322F, [0x6A4173]=0x5E3835, [0x6A417B]=0x653835, [0x6A4A73]=0x5E3F3D, + [0x6A4A83]=0x6B403D, [0x6A5273]=0x5E4543, [0x6A8394]=0x795957, [0x6A9CA4]=0x895854, + [0x6AB4D5]=0xC8463D, [0x6AB4DE]=0xD3443A, [0x6ABDDE]=0xD3443A, [0x6ABDE6]=0xDD4136, + [0x6ABDEE]=0xE83F32, [0x6AC5EE]=0xE83F32, [0x6AC5F6]=0xF23B2E, [0x6AC5FF]=0xFF3729, + [0x6ACDFF]=0xFF3729, [0x73397B]=0x65322F, [0x734183]=0x6B3935, [0x735273]=0x5E4543, + [0x73527B]=0x654543, [0x73A4AC]=0x945C58, [0x73ACB4]=0x9E5954, [0x73ACBD]=0xA95650, + [0x73ACC5]=0xB5524B, [0x73B4C5]=0xB5524B, [0x73B4CD]=0xBE5049, [0x73B4D5]=0xC74E46, + [0x73BDDE]=0xD24C42, [0x73BDE6]=0xDD493E, [0x73C5E6]=0xDD493E, [0x73C5EE]=0xE7463A, + [0x73C5F6]=0xF24236, [0x73CDEE]=0xE7463A, [0x73CDF6]=0xF24236, [0x73CDFF]=0xFF3E30, + [0x73D5FF]=0xFF3E30, [0x7B4183]=0x6B3935, [0x7B418B]=0x723935, [0x7B5A83]=0x6B4C4A, + [0x7B8BAC]=0x95615D, [0x7BA4B4]=0x9E5F5A, [0x7BA4BD]=0xAA5B56, [0x7BB4C5]=0xB35953, + [0x7BB4CD]=0xBD5750, [0x7BBDCD]=0xBD5750, [0x7BBDD5]=0xC6554D, [0x7BBDDE]=0xD25249, + [0x7BC5E6]=0xDC5046, [0x7BC5EE]=0xE74C41, [0x7BC5F6]=0xF2493D, [0x7BCDEE]=0xE74C41, + [0x7BCDF6]=0xF2493D, [0x7BCDFF]=0xFF4437, [0x7BD5FF]=0xFF4437, [0x7BDEFF]=0xFF4437, + [0x834A8B]=0x72403D, [0x834A94]=0x79413D, [0x83528B]=0x724643, [0x835A8B]=0x724C4A, + [0x839CA4]=0x8C6966, [0x83ACBD]=0xA9625D, [0x83B4BD]=0xA9625D, [0x83CDE6]=0xDB564D, + [0x83CDF6]=0xF14F44, [0x83D5F6]=0xF14F44, [0x83D5FF]=0xFF4A3E, [0x83DEFF]=0xFF4A3E, + [0x8B5294]=0x794743, [0x8B529C]=0x804743, [0x8BACAC]=0x956D6A, [0x8BACBD]=0xA86A65, + [0x8BBDBD]=0xA86A65, [0x8BC5D5]=0xC4635C, [0x8BCDE6]=0xDA5D54, [0x8BD5F6]=0xF1564B, + [0x8BD5FF]=0xFF5144, [0x8BDEFF]=0xFF5144, [0x8BE6FF]=0xFF5144, [0x945A9C]=0x804D4A, + [0x945AA4]=0x864E4A, [0x94BDBD]=0xA6726E, [0x94CDDE]=0xCE6861, [0x94D5F6]=0xF05D53, + [0x94DEF6]=0xF05D53, [0x94DEFF]=0xFF574B, [0x94E6FF]=0xFF574B, [0x9C5AA4]=0x864E4A, + [0x9CB4BD]=0xA47A77, [0x9CBDD5]=0xC1736D, [0x9CC5CD]=0xB77671, [0x9CD5E6]=0xD86C65, + [0x9CE6FF]=0xFF5E52, [0xA462AC]=0x91514D, [0xA46AB4]=0x9C544F, [0xA4D5DE]=0xCC7771, + [0xA4DEEE]=0xE36F67, [0xA4E6F6]=0xEF6A61, [0xA4E6FF]=0xFF6459, [0xAC6AB4]=0x9C544F, + [0xAC8BB4]=0x9D6C68, [0xACDEE6]=0xD57B74, [0xACE6FF]=0xFF6A5F, [0xB483BD]=0xA9625D, + [0xB483C5]=0xB2615B, [0xB48BC5]=0xB16863, [0xB4C5D5]=0xBC8A87, [0xB4D5D5]=0xBC8A87, + [0xB4E6E6]=0xD4827D, [0xBD7BC5]=0xB35953, [0xBD83CD]=0xBC5F58, [0xBD9CBD]=0xA47A77, + [0xBDD5D5]=0xB99391, [0xBDDEDE]=0xC5908C, [0xC5D5E6]=0xCF948F, [0xC5DEDE]=0xC39895, + [0xCD94D5]=0xC36C65, [0xCDA4DE]=0xCC7771, [0xCDC5D5]=0xB69C9A, [0xCDDEE6]=0xCC9C99, + [0xCDE6E6]=0xCC9C99, [0xD5B4DE]=0xC88682, [0xD5E6E6]=0xC8A5A3, [0xD5EEEE]=0xD79F9B, + [0xDEE6EE]=0xD2AAA7, [0xDEEEEE]=0xD2AAA7, [0xDEF6F6]=0xE4A19C, [0xE6EEEE]=0xCCB5B4, + [0xE6F6F6]=0xDFABA7, [0xEED5F6]=0xE79791, [0xEEE6EE]=0xCCB5B4, [0xF6E6F6]=0xDFABA7, + [0xFFE6FF]=0xFF968F, + }, + }, + [131] = { + name = "lapras", + hueRange = { min = -15, max = 5 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x398BCD, + slide = { h = 56, l = 2, s = 0 }, + }, + [132] = { + name = "ditto", + hueRange = { min = -60, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xAC6294, + slide = { h = -80, l = -2, s = 1 }, + }, + [133] = { + name = "eevee", + hueRange = { min = -30, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBD7B4A, + slide = { h = -140, l = 1, s = -5 }, + }, + [134] = { + name = "vaporeon", + hueRange = { min = -20, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x6ABDC5, + slide = { h = 87, l = 0, s = 0 }, + }, + [135] = { + name = "jolteon", + hueRange = { min = -25, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFFF00, + slide = { h = -20, l = -1, s = -5 }, + }, + [136] = { + name = "flareon", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xCD624A, + slide = { h = -60, l = 0, s = -2 }, + }, + [137] = { + name = "porygon", + hueRange = { min = -40, max = 40 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEEC5C5, + slide = { h = -90, l = -3, s = 2 }, + }, + [138] = { + name = "omanyte", + hueRange = { min = -15, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xDEBD83, + slide = { h = -30, l = 1, s = -1 }, + }, + [139] = { + name = "omastar", + hueRange = { min = -15, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xD5C583, + slide = { h = -30, l = 1, s = -1 }, + }, + [140] = { + name = "kabuto", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA46220, + slide = { h = 40, l = 2, s = -7 }, + }, + [141] = { + name = "kabutops", + hueRange = { min = -40, max = 50 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x94735A, + slide = { h = 50, l = 0, s = -5 }, + }, + [142] = { + name = "aerodactyl", + hueRange = { min = -60, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xA49CB4, + slide = { h = 50, l = -2, s = 2 }, + }, + [143] = { + name = "snorlax", + hueRange = { min = -10, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xF6E6B4, + slide = { h = 30, l = 1, s = -3 }, + }, + [144] = { + name = "articuno", + hueRange = { min = -20, max = 30 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x31A4D5, + slide = { h = -40, l = 0, s = 1 }, + }, + [145] = { + name = "zapdos", + hueRange = { min = -20, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFF9C39, + slide = { h = -40, l = 2, s = 0 }, + }, + [146] = { + name = "moltres", + hueRange = { min = -20, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xFFD500, + slide = { h = -80, l = 5, s = -3 }, + }, + [147] = { + name = "dratini", + hueRange = { min = -40, max = 20 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x94A4E6, + slide = { h = 40, l = 0, s = 0 }, + }, + [148] = { + name = "dragonair", + hueRange = { min = -30, max = 10 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0x4183BD, + slide = { h = 50, l = 2, s = -3 }, + }, + [149] = { + name = "dragonite", + hueRange = { min = -20, max = 25 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xE6AC5A, + slide = { h = 130, l = 0, s = -4 }, + }, + [150] = { + name = "mewtwo", + hueRange = { min = -70, max = 90 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xBDBDDE, + slide = { h = -40, l = -1, s = -2 }, + }, + [151] = { + name = "mew", + hueRange = { min = -40, max = 60 }, + -- the colour this species is mostly MADE of, and what its shiny + -- shift does to it. A flat sprite cannot be recoloured, only + -- multiplied, and the multiply has to be measured against the + -- body colour: averaged over a balanced set of references a + -- hue rotation cancels itself out to no tint at all. + dom = 0xEEDEEE, + slide = { h = -80, l = 0, s = 0 }, + }, +} diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 367b034..6af0a03 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -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 diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index ed4b6d4..8694046 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -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)) diff --git a/lib/Buildings.lua b/lib/Buildings.lua index a21e515..f11d1ca 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -134,9 +134,112 @@ local function profile() end local models = {} -- ":" -> 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 = {} -- ":" -> 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 diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua index b573115..b001f15 100644 --- a/lib/CatchThrow.lua +++ b/lib/CatchThrow.lua @@ -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 diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 532c3d4..6344b90 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -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 diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua index b62ad2f..e2075ef 100644 --- a/lib/LetsGo.lua +++ b/lib/LetsGo.lua @@ -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 diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 33c44ad..d87112b 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -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 diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index a496760..447d45a 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -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 diff --git a/lib/SettingsHelp.lua b/lib/SettingsHelp.lua new file mode 100644 index 0000000..59fa617 --- /dev/null +++ b/lib/SettingsHelp.lua @@ -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 diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua new file mode 100644 index 0000000..e5ad7fd --- /dev/null +++ b/lib/SettingsMenu.lua @@ -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:" 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 diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 8997a49..6d914fd 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -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 diff --git a/lib/Shadows.lua b/lib/Shadows.lua new file mode 100644 index 0000000..e9eb5ac --- /dev/null +++ b/lib/Shadows.lua @@ -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 diff --git a/lib/Shiny.lua b/lib/Shiny.lua new file mode 100644 index 0000000..bcf32c9 --- /dev/null +++ b/lib/Shiny.lua @@ -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 diff --git a/lib/ShinyBattle.lua b/lib/ShinyBattle.lua new file mode 100644 index 0000000..2a34fa0 --- /dev/null +++ b/lib/ShinyBattle.lua @@ -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 diff --git a/lib/ShinyFx.lua b/lib/ShinyFx.lua new file mode 100644 index 0000000..62c5dc2 --- /dev/null +++ b/lib/ShinyFx.lua @@ -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 diff --git a/lib/ShinyPalette.lua b/lib/ShinyPalette.lua new file mode 100644 index 0000000..a7747c1 --- /dev/null +++ b/lib/ShinyPalette.lua @@ -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 diff --git a/lib/ShinyUI.lua b/lib/ShinyUI.lua new file mode 100644 index 0000000..a4ca0f3 --- /dev/null +++ b/lib/ShinyUI.lua @@ -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 diff --git a/lib/Stadium.lua b/lib/Stadium.lua index 8cccf32..26bbcab 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -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 diff --git a/lib/StadiumBuild.lua b/lib/StadiumBuild.lua index 0440edf..be42966 100644 --- a/lib/StadiumBuild.lua +++ b/lib/StadiumBuild.lua @@ -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 diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua index 81264f7..d706437 100644 --- a/lib/StadiumInstall.lua +++ b/lib/StadiumInstall.lua @@ -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 diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua index e8420f5..d5de5b7 100644 --- a/lib/StadiumMon.lua +++ b/lib/StadiumMon.lua @@ -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 diff --git a/lib/StadiumPack.lua b/lib/StadiumPack.lua index c23fa18..e958938 100644 --- a/lib/StadiumPack.lua +++ b/lib/StadiumPack.lua @@ -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 diff --git a/lib/ViewBox.lua b/lib/ViewBox.lua index f745071..200ef02 100644 --- a/lib/ViewBox.lua +++ b/lib/ViewBox.lua @@ -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 diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index a44e0c1..5b3d7f9 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -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; } diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index e90c45a..6239deb 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -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, diff --git a/main.lua b/main.lua index 04b6ba0..33d1842 100644 --- a/main.lua +++ b/main.lua @@ -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. diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 9cb33c4..e121062 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -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 diff --git a/tests/freemove_probe.lua b/tests/freemove_probe.lua new file mode 100644 index 0000000..47cc62b --- /dev/null +++ b/tests/freemove_probe.lua @@ -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 diff --git a/tests/letsgo_empty.lua b/tests/letsgo_empty.lua new file mode 100644 index 0000000..9d8646b --- /dev/null +++ b/tests/letsgo_empty.lua @@ -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 diff --git a/tests/letsgo_route6.lua b/tests/letsgo_route6.lua new file mode 100644 index 0000000..4d668f4 --- /dev/null +++ b/tests/letsgo_route6.lua @@ -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 diff --git a/tests/shiny_obs.lua b/tests/shiny_obs.lua new file mode 100644 index 0000000..8c29e73 --- /dev/null +++ b/tests/shiny_obs.lua @@ -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 diff --git a/tests/shiny_one.lua b/tests/shiny_one.lua new file mode 100644 index 0000000..95102e1 --- /dev/null +++ b/tests/shiny_one.lua @@ -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 diff --git a/tests/shiny_palette_fixture.lua b/tests/shiny_palette_fixture.lua new file mode 100644 index 0000000..e45f6e0 --- /dev/null +++ b/tests/shiny_palette_fixture.lua @@ -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}, +} diff --git a/tests/shiny_run.sh b/tests/shiny_run.sh new file mode 100644 index 0000000..66c8c40 --- /dev/null +++ b/tests/shiny_run.sh @@ -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" diff --git a/tests/shiny_shots.lua b/tests/shiny_shots.lua new file mode 100644 index 0000000..50c6bd6 --- /dev/null +++ b/tests/shiny_shots.lua @@ -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 diff --git a/tests/shiny_test.lua b/tests/shiny_test.lua new file mode 100644 index 0000000..32d670a --- /dev/null +++ b/tests/shiny_test.lua @@ -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) diff --git a/tests/stadium_extract_test.lua b/tests/stadium_extract_test.lua index 626d517..2bb2142 100644 --- a/tests/stadium_extract_test.lua +++ b/tests/stadium_extract_test.lua @@ -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 diff --git a/tools/dump_shiny_palettes.lua b/tools/dump_shiny_palettes.lua new file mode 100644 index 0000000..cf4c90f --- /dev/null +++ b/tools/dump_shiny_palettes.lua @@ -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") diff --git a/tools/shiny_colors.py b/tools/shiny_colors.py new file mode 100644 index 0000000..07878c4 --- /dev/null +++ b/tools/shiny_colors.py @@ -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)) diff --git a/tools/shiny_sprite_sheet.py b/tools/shiny_sprite_sheet.py new file mode 100644 index 0000000..508d8e8 --- /dev/null +++ b/tools/shiny_sprite_sheet.py @@ -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))