diff --git a/CHANGELOG.md b/CHANGELOG.md index 18972bb..fc3e552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,350 @@ # 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 + +- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new + three-rung row. CATCH ONLY changes nothing about the game except the + throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the + BALL row of the safari menu) opens capture mode instead of the automatic + toss. FULL makes wild encounters the real Let's Go article: the + encounter IS the catch -- it opens in throwing mode and stays there, + the foe never takes a turn, your own Pokemon is never sent out or + shown (no back pic, no model, no HUD), and B runs, which from a catch + encounter always works. Poke/Great/Ultra Balls are half price at every + mart, and EXPERIENCE works the way that game's does: every healthy + party member gains from every catch AND every trainer knockout, each + one measured against its OWN level through the Gen VII scaled formula + -- which is why Let's Go ships no EXP.ALL, and why a level 5 party + member takes several times what a level 45 one does from the very same + fight. A catch adds the throw stack on top: grade, first ball of the + encounter, new species, and a persistent catch combo. CATCH ONLY + leaves experience exactly as the original game had it. + + **The throw.** The camera locks HEAD ON with the wild Pokemon -- its + own seat on the arena's axis, no drift, no steer -- and a real 3D Poke + Ball (modelled and animated for this: hinged lid, capture beam, + squash-click, decaying wobble, caught stars, breakout burst; GREAT + blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the + bottom of the frame. The ball rides UNDER the finger -- mouse, touch, + or the right stick -- and releasing throws it with the swipe's own + velocity: forward from how hard, height from its rise, side from its + slant, gravity and collision deciding the rest, with a bearing-and- + range assist trimming honest errors. Circling the ball WINDS it -- the + spin visibly builds with the gesture to a cap and bleeds off when the + hand pauses -- and only a ball at the cap flies with the late-biting + curve. Contact is against the creature's own GEOMETRY: a pic foe is + its sprite's opaque pixels (a ball through the gap under a wing flies + on), a STADIUM foe its model's measured height, girth and hover. The + timing ring pulses on the creature, coloured by the Gen 1 odds, and is + judged AT the moment of contact: inside earns NICE / GREAT / + 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 + ball is a spent ball. Outside FULL, a failed throw still costs the + turn it always did. Needs the staged 3D battle standing (3D-BTL on, a + 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 dbaf442..8694046 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -48,6 +48,29 @@ local Map = require("src.world.Map") local BattleScene = {} +-- ------- LET'S GO capture mode's stake in this scene +-- +-- One table while a capture session runs, nil otherwise (see +-- lib/CatchThrow.lua, which owns it): +-- +-- hidePlayer the player's side stays out of the shot entirely -- no +-- card here, no model (Stadium reads this same table), no +-- pinned back pic (OverworldBattle reads it too) +-- shrink the foe's scale while the ball drinks it in, applied +-- about its chest so it collapses toward the beam +-- draw(pull) the Poke Ball, drawn after the Stadium models -- same +-- depth buffer, same flash window, same camera +-- cast(sm) the same ball into the sun's pass +-- sig() a term for the cached shadow signature, so a ball in +-- flight re-casts and a resting scene does not +-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's +-- BattleState:draw wrap in the GB frame +-- +-- It lives HERE, not on OverworldBattle, because every consumer below +-- already requires BattleScene and the one file that writes it requires +-- both -- this is the spot with no require cycle. +BattleScene.capture = nil + -- The GB frame the battle screen is drawn in, and the frame BattleCam's rig -- is solved against. BattleScene.GB_W = 160 @@ -195,14 +218,29 @@ end local function monCards(arena, groundY, textures) local out = {} if not textures then return out end + local cap = BattleScene.capture for _, side in ipairs({ "enemy", "player" }) do local tex = textures[side] local cell = (side == "player") and arena.player or arena.enemy + -- capture mode: the player's side is out of the shot (the seat looks + -- over an empty shoulder), and OverworldBattle.textures already + -- skipped rendering it -- this is the belt to that suspender + if side == "player" and cap and cap.hidePlayer then tex = nil end if tex and tex.canvas and cell then local mirror = (side == "player") and not tex.trainer - out[#out + 1] = { tex = tex.canvas, - model = monMatrix(tex, cell[1], groundY, cell[2], - mirror) } + local model = monMatrix(tex, cell[1], groundY, cell[2], mirror) + -- the foe drinking into the ball: scaled about its own chest, in + -- world space so the composed card matrix needs no decomposition + if side == "enemy" and cap and cap.shrink then + local k = cap.shrink + local ax, ay, az = cell[1], groundY + 8, cell[2] + model = Mat4.mul( + Mat4.mul(Mat4.translate(ax, ay, az), + Mat4.mul(Mat4.scale(k, k, k), + Mat4.translate(-ax, -ay, -az))), + model) + end + out[#out + 1] = { tex = tex.canvas, model = model } end end return out @@ -313,6 +351,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token) -- from somewhere new must be re-cast from there math.floor(ShadowMap.KX * 128), math.floor(ShadowMap.KZ * 128) } + -- a capture session's ball moves through the sun's world too; its term + -- is quantised inside sig() so the cache re-renders on real movement + -- and not on every frame the ball rests + local cap = BattleScene.capture + if cap and cap.sig then + local okSig, sig = pcall(cap.sig) + parts[#parts + 1] = okSig and sig or "cap" + end for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end return table.concat(parts, ",") end @@ -377,6 +423,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, -- the water. Un-snugged for the same reason: snug is a bias for a card -- rooted to the ground plane, and a model has thickness of its own. pcall(function() V.require("Stadium").cast(ShadowMap) end) + -- the capture session's ball, by the same reasoning: real geometry, its + -- shadow is half of what sells the arc + local cap = BattleScene.capture + if cap and cap.cast then pcall(cap.cast, ShadowMap) end ShadowMap.finish(sig) end @@ -520,7 +570,25 @@ function BattleScene.render(state, arena, textures, token) end local groundY = BattleScene.groundY(host, arena) - local cam, pitch = BattleCam.rig(arena, groundY) + -- A capture session brings a camera of its own: the head-on seat, on + -- the arena's axis looking straight at the foe, in place of the solved + -- over-the-shoulder shot. Everything downstream -- the letterbox fov, + -- the pins, the sun, the cards yawing to the eye -- is generic over + -- whichever camera this is. + local cam, pitch, capFrameH + local cap = BattleScene.capture + if cap and cap.rig then + local okRig, c, p, fh = pcall(cap.rig, arena, groundY) + -- 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) local cx, cy = arena.mid[1], arena.mid[2] @@ -529,7 +597,8 @@ function BattleScene.render(state, arena, textures, token) -- the player's zoom is part of this: the sun's box is fitted to what the -- frame holds, so a shot pulled wide has to light the ground it just -- brought into view rather than the ground the rig alone would have - local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) + local vh = (capFrameH or BattleCam.frameH(arena)) * ph + / (BattleScene.GB_H * s) local vw = vh * pw / ph -- the cards need the camera's eye to face it, so the rig has to be live @@ -662,6 +731,17 @@ function BattleScene.render(state, arena, textures, token) V.require("Stadium").draw(BattleBillboard.PULL) end) if not okStadium then V.require("Stadium").report(stadiumErr) end + -- the capture session's Poke Ball, still inside the flash window and + -- with the mons' own camera-ward pull, so a ball crossing in front of + -- 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 @@ -669,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)) @@ -695,25 +793,47 @@ function BattleScene.render(state, arena, textures, token) -- How wide one overworld square is on screen where each mon stands, in -- GB pixels. This is what the pics are scaled to: a mon covers its own -- square and no more, at whatever the drift has done to the distance. + -- + -- Measured along BOTH map axes and answered as the larger, as a full + -- 2D screen distance. One axis alone breaks the moment a camera looks + -- ALONG it: the capture seat stands on the arena's own axis, and on a + -- quarter-turned arena that axis is world X -- the ±X probe points + -- then project to the same pixel and the span reads zero, which + -- collapsed the ring and blew up the throw's world-per-pixel mapping. local half = BattleScene.CELL / 2 - local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY, - arena.player[2], lx, ly, s, pw, ph) - local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY, - arena.player[2], lx, ly, s, pw, ph) - local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY, - arena.enemy[2], lx, ly, s, pw, ph) - local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY, - arena.enemy[2], lx, ly, s, pw, ph) - if not (pl and pr and el and er) then return end + local function cellSpan(wx, wz) + local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz, + lx, ly, s, pw, ph) + local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz, + lx, ly, s, pw, ph) + local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half, + lx, ly, s, pw, ph) + local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half, + lx, ly, s, pw, ph) + if not (x1 and x2 and x3 and x4) then return nil end + local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2) + local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2) + return math.max(ew, ns) + end + local pSpan = cellSpan(arena.player[1], arena.player[2]) + local eSpan = cellSpan(arena.enemy[1], arena.enemy[2]) + if not (pSpan and eSpan) then return end out = { canvas = canvas, player = { pmx, pmy }, enemy = { emx, emy }, - playerSpan = math.abs(pr - pl), - enemySpan = math.abs(er - el), + playerSpan = pSpan, + enemySpan = eSpan, -- the letterbox, so the depth-of-field pass can put its sharp band on -- the two marks rather than on a fraction of the window lx = lx, ly = ly, scale = s, pw = pw, ph = ph, + -- the camera and its combined matrix, for anything that reasons + -- about this shot from outside the render -- the capture mode's + -- throw is solved in these (aim errors along this eye's own right + -- and forward, contact judged through this vp) + eye = { cam.eye[1], cam.eye[2], cam.eye[3] }, + focus = { cam.focus[1], cam.focus[2], cam.focus[3] }, + vp = vp, -- and the hour's light, for anything drawn over this shot that is NOT -- geometry and so never went past the shader that applied it -- the back -- pic pinned to the menu (see OverworldBattle.backPinned). Neutral 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 new file mode 100644 index 0000000..b001f15 --- /dev/null +++ b/lib/CatchThrow.lua @@ -0,0 +1,1770 @@ +-- LET'S GO capture mode: the throw itself. +-- +-- The player sees the wild Pokemon from the staged battle's own +-- over-the-shoulder seat -- held perfectly still for the duration, with +-- their own Pokemon nowhere in the shot -- a Poke Ball hanging in the +-- foreground, and a timing ring pulsing on the foe. A flick of the mouse, +-- a finger, or the right stick throws the ball along a solved arc; spin +-- the ball first and it flies with a visible curve. Where the ball crosses +-- the foe's plane is judged in SCREEN space against the ring -- the player +-- aimed at pixels, so pixels are what they are graded on -- and a throw +-- inside the shrinking ring earns NICE / GREAT / EXCELLENT, which +-- multiplies the Gen 1 catch roll the engine then makes. The ball opens, +-- drinks the Pokemon in, drops, rocks once per engine shake, and either +-- clicks with stars or bursts the Pokemon back out. +-- +-- ------- what this file owns, and what it borrows +-- +-- It owns the SESSION: input sampling, the flick estimate, the arc, the +-- ring, the grading, and the choreography of the Pokeball prop. Everything +-- consequential is borrowed from the engine so the outcome is exactly a +-- Gen 1 ball throw: the roll is battle:catchAttempt (status, HP and ball +-- factors intact, our throw grade folded into the rate), the outcome texts +-- and the enemy's revenge turn are the same queue throwBall builds, and a +-- catch lands in storeCaughtMon -- dex page, nickname, box overflow and +-- the pokemon.caught event all included. While the session runs the battle +-- is FROZEN by parking battle.phase on a value the engine's update has no +-- branch for; the resolution puts "messages" back and the engine finishes +-- the fight believing it threw the ball itself. +-- +-- ------- the input model (design: flick-to-throw) +-- +-- All pointer samples are kept in GB-frame pixels (160x144), the same +-- space the ring is drawn in. The flick velocity is a least-squares slope +-- over the last 110 ms -- never last-minus-previous, which one frame of +-- jitter destroys. Strength comes from speed, direction from the fit, and +-- the landing point is "the spot the flick pointed at": release position +-- plus direction times a reach that grows with strength. Short flicks land +-- short, wild flicks sail long, and a ±30% speed error still lands inside +-- the outer ring, which is what keeps the mechanic hard rather than broken. +-- The right stick plays the same game with a virtual finger standing at +-- centre + stick * radius, so pad players flick by flicking the stick. +-- +-- Spin is a circular gesture around the held ball: accumulated signed +-- angle arms the curve past 1.25 turns, and once armed it latches. A +-- curved ball hooks late in flight (the ramp is what forces aiming off to +-- the opposite side) with a 40% assist folded into the solve, so the curve +-- is a flourish first and a skill test second. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") +local Pokeball = V.require("Pokeball") +local BattleScene = V.require("BattleScene") +local BattleCam = V.require("BattleCam") + +local CatchThrow = {} + +-- the parked phase: any value BattleState:update has no branch for freezes +-- the fight; namespaced so no future engine phase can collide with it +CatchThrow.PHASE = "dramaticShapeCapture" + +-- ------- tuning +-- +-- Screen quantities are GB pixels (the battle frame is 160x144); world +-- quantities are world pixels (a map cell is 16); times are seconds. + +local WINDOW = 0.11 -- flick fit window +local S_MIN = 55 -- below this the release is a hesitation +-- a throw must point up-screen at least this much. LENIENT on purpose: +-- the gate only has to reject a plainly downward drag +local MIN_UP = 0.08 + +-- ------- the throw is the SWIPE'S OWN VELOCITY +-- +-- The reference model (and the one that finally felt right): the ball is +-- dragged under the finger, and releasing LAUNCHES IT with the swipe's +-- velocity -- forward speed from how hard the swipe was, height from its +-- vertical, side from its horizontal, each clamped to a sane range like a +-- rigidbody throw. No aim point is picked and no arc is solved: gravity +-- and the collision decide what the throw earns. A soft flick drops +-- short, a wild one sails over, and the assist below is the only mercy. +-- +-- CALIBRATED against the staging rather than picked: the throw is 48 +-- world pixels (three cells) to a body about ten pixels up, so the +-- vertical speed that actually ARRIVES is somewhere in the 65-95 range +-- across every forward speed worth throwing at. The first cut had gains +-- of 3.0 and a 150/110 ceiling, which meant an ordinary ~190 GB px/s +-- swipe already saturated both clamps -- every throw left at maximum, +-- and maximum was well above anything that lands, so the ball sailed +-- over the Pokemon no matter how it was flicked. These put a +-- comfortable swipe in the middle of the band and leave the ceilings +-- where a genuinely hard throw still reaches them. +local K_FWD, K_UP, K_LAT = 2.4, 1.6, 1.0 -- swipe (world px/s) -> velocity +local F_MIN, F_MAX = 48, 140 -- forward speed clamp, world px/s +local U_MIN, U_MAX = 34, 92 -- upward +local L_MAX = 38 -- sideways +-- ------- and the ceiling that ends overthrowing +-- +-- The launch height is never allowed to exceed what THIS throw's own +-- forward speed needs to arrive, by more than a margin. That is the +-- direct cure for the failure the gains alone could not fix: on a fixed +-- camera there is no depth cue for "too high", so a firm flick would +-- sail the ball clean over the Pokemon every time and the player had no +-- way to read why. Under the cap a throw can still fall SHORT (too +-- weak) or go WIDE (bad bearing) -- both of which are legible, because +-- you watch the ball land -- but it can no longer be lost over the top. +local OVER_CAP = 1.12 +-- directional assist: the HORIZONTAL launch direction leans this fraction +-- toward the Pokemon. Speed, height and the short/long answer stay the +-- player's; the assist only trims the bearing, so an honest throw at the +-- creature connects instead of clipping past its ear. +local MAGNET = 0.3 +-- range assist: the launch's HEIGHT leans this fraction toward what its +-- own forward speed would need to arrive at the foe's body. Pure velocity +-- throws punish the lob's shape as hard as its speed; this keeps speed as +-- the skill and stops a good read from sailing a ball-width over. Half, +-- not a third: the arc's SHAPE is the least legible thing about a throw +-- on a fixed camera -- there is no depth cue for how high is too high -- +-- so it is the part worth forgiving, while distance and bearing stay the +-- player's to get right. +local V_ASSIST = 0.5 +local T_REF = 0.6 -- the tap-throw / Master Ball solve time + +local GRAVITY = 300 -- world px/s^2 -- ~2x earth at 16 px/metre, + -- the platformer exaggeration the arc needs +local H_STEP = 1 / 120 -- fixed integration step + +-- ------- spin +-- +-- The wind-up is a METER now, not a switch: every turn of the gesture adds +-- to it and it bleeds away when the hand stops, so the ball visibly spins +-- up with the player -- faster the more they wind -- to a cap. Only a ball +-- AT the cap curves when thrown; anything less is style. +local SPIN_FULL = 32 -- radians of gesture from still to the cap +local ROLL_MAX = 24 -- the cap's visible spin, rad/s +local SPIN_DECAY = 0.45 -- meter per second, once the hand pauses +local SPIN_IDLE = 0.2 -- how long a pause before the bleed starts +local ARM_AT, DISARM_AT = 0.99, 0.80 -- hysteresis on "at the cap" + +local CURVE_GAIN = 120 -- lateral pull at full spin, world px/s^2 +local CURVE_RAMP0, CURVE_RAMP1 = 0.25, 0.70 -- the hook bites late +local ASSIST = 0.4 -- how much of the curve the solve pre-corrects +local K_RAMP = 0.14 -- integral of the ramp, for the assist + +local RING_PERIOD = 2.2 -- shrink cycle +local RING_HOLD = 0.15 -- pause at the smallest +local RING_MIN = 0.12 -- the Excellent floor +local CHEST = 9 -- ring/beam height over the foe's feet, world px + +local SUCK_T = 0.6 -- the Pokemon drinking into the ball +local DROP_T = 0.45 -- fall to the ground +local SHAKE_GAP = 0.42 -- pause between rocks +local STICK_R = 34 -- the virtual finger's orbit, GB px + +-- stick flick thresholds, in stick units (full deflection = 1) per second +local STICK_VEL = 5.5 +local STICK_GRAB = 0.30 + +-- the Let's Go throw-grade multipliers, applied to the Gen 1 catch rate +-- and to the capture-experience stack (Bulbapedia's Let's Go tables) +local TIER_MULT = { EXCELLENT = 2.0, GREAT = 1.5, NICE = 1.1 } + +-- ------- session state + +local S = nil + +local function game() return require("src.core.Game") end + +local function sound(name) + pcall(function() + require("src.core.Sound").play(game().data, name) + end) +end + +local function emit(name, payload) + pcall(function() require("src.mods.Runtime").emit(name, payload) end) +end + +local function OB() return V.require("OverworldBattle") end + +function CatchThrow.active() + return S ~= nil and S.phase ~= "epilogue" +end + +function CatchThrow.session() + return S +end + +-- ------- geometry helpers +-- +-- The shot BattleScene hands back carries the camera (eye/focus), the +-- combined matrix (vp) and the letterbox, which together answer every +-- space question the session has: world -> GB via BattleScene.toGB, and +-- the camera's flat forward/right for aiming errors. + +local function camBasis(shot) + local e, f = shot.eye, shot.focus + local fx, fz = f[1] - e[1], f[3] - e[3] + local l = math.sqrt(fx * fx + fz * fz) + if l < 1e-6 then fx, fz = 0, -1 else fx, fz = fx / l, fz / l end + -- right-handed about +Y up: right = fwd x up + return fx, fz, -fz, fx +end + +local function toGB(shot, wx, wy, wz) + return BattleScene.toGB(shot.vp, wx, wy, wz, + shot.lx, shot.ly, shot.scale, shot.pw, shot.ph) +end + +-- the yaw that turns a thing at (x, z) to face this shot's eye +local function eyeYaw(x, z) + local e = S.shot and S.shot.eye + if not e then return 0 end + return math.atan2(e[1] - x, e[3] - z) +end + +-- ------- the capture seat's measurements +-- +-- HEAD ON: the eye stands on the arena's own axis, behind the player's +-- cell, looking straight at the foe -- the GO framing, not the battle's +-- over-the-shoulder rig. Declared here rather than beside captureRig +-- below because the ray reconstruction underneath needs the lens. +local SEAT_BACK = 46 -- eye behind the player's cell, world px +local SEAT_UP = 13 -- and above the floor +local SEAT_FOV = math.rad(26) +local SEAT_FRAME = 44 -- world height the sun's box is fitted to + +-- ------- the ray a GB-frame point looks along +-- +-- The drag's whole job is "the ball is under the finger", and only the +-- real projection answers that: an offset scaled by some world-per-pixel +-- estimate is right at one depth and wrong everywhere else, and it has +-- no way to say what the bottom of the screen even means. This is the +-- ordinary pinhole reconstruction from the seat's own basis -- the +-- vertical field covers the GB's 144 rows, and the horizontal falls out +-- of square pixels (which is why both axes divide by the same half +-- height). +-- +-- Keyed to SEAT_FOV rather than to the shot, deliberately: the shot's +-- fov has been widened to the window (BattleScene.letterboxFov) and what +-- is wanted here is the lens the GB frame was framed with, which is the +-- capture rig's own and nobody else's. +local function rayThrough(gx, gy) + local shot = S and S.shot + if not shot then return nil end + local e, f = shot.eye, shot.focus + local dx, dy, dz = f[1] - e[1], f[2] - e[2], f[3] - e[3] + local l = math.sqrt(dx * dx + dy * dy + dz * dz) + if l < 1e-6 then return nil end + dx, dy, dz = dx / l, dy / l, dz / l + -- right = forward x worldUp; camUp = right x forward + local rx, rz = -dz, dx + local rl = math.sqrt(rx * rx + rz * rz) + if rl < 1e-6 then return nil end + rx, rz = rx / rl, rz / rl + local ux, uy, uz = -rz * dy, rz * dx - rx * dz, rx * dy + local half = BattleScene.GB_H / 2 + local t = math.tan(SEAT_FOV / 2) + local ax = (gx - BattleScene.GB_W / 2) / half * t + local ay = (gy - half) / half * t + local vx = dx + rx * ax - ux * ay + local vy = dy - uy * ay + local vz = dz + rz * ax - uz * ay + local vl = math.sqrt(vx * vx + vy * vy + vz * vz) + if vl < 1e-6 then return nil end + return vx / vl, vy / vl, vz / vl +end + +-- Where the held ball hangs: over the PLAYER'S OWN CELL, which capture +-- mode has just emptied. Not in front of the camera -- the default battle +-- rig is a long lens standing five blocks back, and anything a hand's +-- reach from THAT eye is a blimp filling the frame. The player's cell is +-- where the trainer stands, it sits bottom-centre of the head-on seat +-- below, and it makes the throw a real three-cell lob at 16 pixels a +-- metre instead of an eleven-metre crane shot from the sky. +local HAND_HOVER = 6.5 -- the held ball's height over the floor +local function handPos() + local p = S.playerPos + return { p[1], p[2] + HAND_HOVER, p[3] } +end + +-- The seat itself, from the measurements above: the foe centred, the held +-- 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 * back, eyeY, pz - az * back }, + focus = { ex, groundY + 8, ez }, + fov = SEAT_FOV, + } + -- 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 +-- cell's ground point, but a species' art sits wherever the artist put it +-- in the frame -- a bird hovers half a slot over its own feet row -- so +-- the session measures the foe's rendered pic once (measureArt below) and +-- centres on the art's opaque box. The conversion is the card's own +-- arithmetic: one pic pixel is FULL_W/FULL_PIC world pixels, and a cell +-- is enemySpan GB pixels, so a pic pixel is span/56 GB pixels on screen. +-- The heuristic fallback (no pic: a STADIUM model, or a readback that +-- failed) is a chest height over the mark. The outer clamp is the +-- design's 0.06..0.18 screen heights, in GB pixels. +local function ringGeometry(shot) + local span = shot.enemySpan or 16 + local b = S and S.artBox + if b then + local k = span / 56 + local cx = shot.enemy[1] + ((b.x0 + b.x1) / 2 - b.ax) * k + local cy = shot.enemy[2] - (b.ay - (b.y0 + b.y1) / 2) * k + local r = math.max(b.x1 - b.x0, b.y1 - b.y0) * 0.55 * k + return cx, cy, math.max(9, math.min(26, r)) + end + -- no pic to measure (a Stadium model, or a readback still pending): + -- the collision body doubles as the ring's anchor, in the same + -- world-to-screen scale a cell provides + local body = (S and S.body) or { r = 8, yOff = 8 } + local outer = math.max(9, math.min(26, body.r * 1.25 * span / 16)) + return shot.enemy[1], shot.enemy[2] - body.yOff * span / 16, outer +end + +-- read the foe's pic back once and box its opaque pixels. A few thousand +-- getPixel calls on a 160x144 canvas, paid once per session -- the ring +-- then sits on the Pokemon for every species without a per-species table. +local function measureArt() + local ok, box = pcall(function() + local tex = OB().enemyTexture() + if not (tex and tex.canvas and tex.canvas.newImageData) then return nil end + local data = tex.canvas:newImageData() + local w, h = data:getWidth(), data:getHeight() + local x0, y0, x1, y1 + for y = 0, h - 1, 2 do + for x = 0, w - 1, 2 do + local _, _, _, a = data:getPixel(x, y) + if a > 0.1 then + if not x0 or x < x0 then x0 = x end + if not x1 or x > x1 then x1 = x end + if not y0 or y < y0 then y0 = y end + if not y1 or y > y1 then y1 = y end + end + end + end + if not x0 then + if data.release then data:release() end + return nil + end + -- the ImageData is KEPT: it is the foe's collision geometry -- the + -- flight samples it per plane crossing -- and it is released when the + -- session is swept (endSession / battle.ended) + return { ax = tex.ax, ay = tex.ay, x0 = x0, y0 = y0, x1 = x1, y1 = y1, + data = data } + end) + return (ok and box) or nil +end + +-- is there sprite under this pic pixel, within `r` pixels? Nine taps -- +-- centre and a ring -- which at the dilation radii in play reads a hit +-- anywhere the ball's silhouette overlaps the art's. +local function maskHit(data, x, y, r) + local ok, hit = pcall(function() + local w, h = data:getWidth(), data:getHeight() + local function tap(px, py) + px, py = math.floor(px + 0.5), math.floor(py + 0.5) + if px < 0 or py < 0 or px >= w or py >= h then return false end + local _, _, _, a = data:getPixel(px, py) + return a > 0.1 + end + if tap(x, y) then return true end + for i = 0, 7 do + local a = i * math.pi / 4 + if tap(x + math.cos(a) * r, y + math.sin(a) * r) then return true end + end + return false + end) + return ok and hit or false +end + +-- The foe's BODY -- where the ball has to physically arrive, and what the +-- ring is sized on. Measured from whichever the foe actually is: the pic's +-- opaque box (a hovering bird is hit where the bird is drawn, not where +-- its shadow falls), or the Stadium model's own height and footprint. A +-- default torso stands in until one of them answers; measured=false says +-- keep trying. +local function measureBody() + local box = measureArt() + if box then + S.artBox = box + local k = 16 / 56 + S.body = { + r = math.max(5, math.min(14, + math.max(box.x1 - box.x0, box.y1 - box.y0) * k * 0.5)), + yOff = math.max(4, (box.ay - (box.y0 + box.y1) / 2) * k), + hh = math.max(4, (box.y1 - box.y0) * k * 0.55), + } + S.bodyMeasured = true + return + end + local ok, body = pcall(function() + return V.require("Stadium").captureBody() + end) + if ok and body then + S.body = body + S.bodyMeasured = true + end +end + +-- named for the drivers: where the ball hangs and where the ring sits, in +-- GB pixels plus the live letterbox -- everything a scripted flick needs +-- to aim like a hand would +function CatchThrow._aimInfo() + if not (S and S.shot) then return nil end + local cx, cy, outer = ringGeometry(S.shot) + return { hand = { S.handGB[1], S.handGB[2] }, + ring = { cx, cy }, outer = outer, + lx = S.shot.lx, ly = S.shot.ly, scale = S.shot.scale, + pw = S.shot.pw, ph = S.shot.ph } +end + +-- the ring's inner fraction right now: 1 -> RING_MIN, hold, snap back +local function ringRho(t) + local cycle = RING_PERIOD + RING_HOLD + local u = t % cycle + if u >= RING_PERIOD then return RING_MIN end + return 1 - (1 - RING_MIN) * (u / RING_PERIOD) +end + +-- ------- flick estimation + +local function pushSample(x, y) + local n = S.samples + n[#n + 1] = { x = x, y = y, t = S.clock } + if #n > 32 then table.remove(n, 1) end +end + +-- least-squares velocity over the window before `at`; also answers the +-- window's peak two-sample speed and the mean speed of the last 40 ms, +-- which the dead-flick gate reads +local function flickFit(at) + local xs, n = S.samples, 0 + local sx, sy, st = 0, 0, 0 + local first = at - WINDOW + local pts = {} + for _, p in ipairs(xs) do + if p.t >= first and p.t <= at then + pts[#pts + 1] = p + sx, sy, st = sx + p.x, sy + p.y, st + p.t + n = n + 1 + end + end + if n < 3 then return nil, nil, n end + local mt, mx, my = st / n, sx / n, sy / n + local num_x, num_y, den = 0, 0, 0 + for _, p in ipairs(pts) do + local d = p.t - mt + num_x = num_x + d * (p.x - mx) + num_y = num_y + d * (p.y - my) + den = den + d * d + end + if den < 1e-9 then return nil, nil, n end + local vx, vy = num_x / den, num_y / den + local peak, tailSum, tailN = 0, 0, 0 + for i = 2, #pts do + local a, b = pts[i - 1], pts[i] + local dt = b.t - a.t + if dt > 1e-5 then + local sp = math.sqrt((b.x - a.x) ^ 2 + (b.y - a.y) ^ 2) / dt + if sp > peak then peak = sp end + if b.t > at - 0.04 then tailSum, tailN = tailSum + sp, tailN + 1 end + end + end + local tail = tailN > 0 and tailSum / tailN or 0 + return vx, vy, n, peak, tail +end + +-- ------- spin gesture +-- +-- A METER, not a switch. Circling the held ball winds it: every radian of +-- gesture adds to the meter and the ball's screen-plane roll follows it, +-- so the ball visibly spins faster the more it is wound, to a cap. Pause +-- and it bleeds back down (the decay lives in the aim tick). Winding the +-- OTHER way unwinds first. Only a ball at the cap curves when thrown. +local function feedSpin(x, y) + -- About the CENTROID of the recent gesture, not about any fixed point: + -- the ball rides under the finger, so the finger is never orbiting the + -- ball -- it is orbiting the middle of the loop it is drawing, and that + -- is what the centroid of the last few samples is. It also gives the + -- straight-drag rejection for free: on a straight swipe the centroid + -- trails along the line, so both spokes point the same way and the + -- accumulated angle stays at nothing. + local n = #S.samples + if n < 4 then return end + local sx, sy, count = 0, 0, 0 + for i = math.max(1, n - 11), n do + sx, sy, count = sx + S.samples[i].x, sy + S.samples[i].y, count + 1 + end + local cx, cy = sx / count, sy / count + local px, py = S.spinPrev and S.spinPrev[1], S.spinPrev and S.spinPrev[2] + S.spinPrev = { x, y } + if not px then return end + local ax, ay = px - cx, py - cy + local bx, by = x - cx, y - cy + local ra = math.sqrt(ax * ax + ay * ay) + local rb = math.sqrt(bx * bx + by * by) + if ra < 4 or rb < 4 then return end -- thumb jitter, not an orbit + local d = math.atan2(ax * by - ay * bx, ax * bx + ay * by) + if d == 0 then return end + local sign = d > 0 and 1 or -1 + if sign ~= S.spinSign and S.spinLevel > 0 then + -- winding against the charge unwinds it, twice as fast as it built + S.spinLevel = math.max(0, S.spinLevel - math.abs(d) * 2 / SPIN_FULL) + if S.spinLevel == 0 then S.spinSign = sign end + else + S.spinSign = sign + S.spinLevel = math.min(1, S.spinLevel + math.abs(d) / SPIN_FULL) + end + S.spinT = S.clock + if not S.spinArmed and S.spinLevel >= ARM_AT then + S.spinArmed = true + sound("Press_AB") + end +end + +-- ------- the throw + +local function smoothstep(a, b, t) + local u = math.max(0, math.min(1, (t - a) / (b - a))) + return u * u * (3 - 2 * u) +end + +-- the moment the ball leaves the hand: pay for it, count it, sound it +local function paidAndAway() + if not S.consumed then + if S.safari then + local st = S.battle.safari + if st then st.balls = math.max(0, st.balls - 1) end + else + pcall(function() + require("src.inventory.Bag").remove(game().save, S.ballId, 1) + end) + end + S.consumed = true + end + S.battle.dramaticShapeThrows = (S.battle.dramaticShapeThrows or 0) + 1 + S.phase = "flight" + S.tFly, S.acc = 0, 0 + -- a charged ball keeps its screen-plane roll all the way in; a plain + -- throw tumbles end over end, harder the less it was wound + S.ballInst.tumble = S.spinArmed and 0 or 15 * (1 - S.spinLevel) + sound("Ball_Toss") +end + +-- estimate how long the flight will take to reach the foe, for the curve +-- ramp and the give-up timer -- with a thrown VELOCITY there is no solved +-- T any more, only this +local function estimateT() + local p, E = S.ballInst.pos, S.enemyPos + local dx, dz = E[1] - p[1], E[3] - p[3] + local horiz = math.sqrt(dx * dx + dz * dz) + local hs = math.sqrt(S.vel[1] ^ 2 + S.vel[3] ^ 2) + S.T = math.max(0.35, math.min(1.0, horiz / math.max(20, hs))) +end + +-- launch with the swipe's own velocity (GB px/s in, world velocity out) +local function launchVelocity(vgx, vgy) + local shot = S.shot + local fx, fz, rx, rz = camBasis(shot) + -- what a GB pixel is worth at the BALL's own depth: the swipe moved the + -- ball, so the ball's speed is the swipe's speed in its own plane + local wpg = 16 / math.max(6, shot.playerSpan or 16) + local speed = math.sqrt(vgx * vgx + vgy * vgy) * wpg + local fwd = math.max(F_MIN, math.min(F_MAX, speed * K_FWD)) + local up = math.max(U_MIN, math.min(U_MAX, (-vgy) * wpg * K_UP)) + local lat = math.max(-L_MAX, math.min(L_MAX, vgx * wpg * K_LAT)) + -- the horizontal launch, leaned MAGNET of the way toward the Pokemon + local hx, hz = fx * fwd + rx * lat, fz * fwd + rz * lat + local hmag = math.sqrt(hx * hx + hz * hz) + local p, E = S.ballInst.pos, S.enemyPos + local tx, tz = E[1] - p[1], E[3] - p[3] + local tl = math.sqrt(tx * tx + tz * tz) + if hmag > 1e-6 and tl > 1e-6 then + local nx = hx / hmag * (1 - MAGNET) + tx / tl * MAGNET + local nz = hz / hmag * (1 - MAGNET) + tz / tl * MAGNET + local nl = math.sqrt(nx * nx + nz * nz) + hx, hz = nx / nl * hmag, nz / nl * hmag + end + -- the range assist: what height would land THIS forward speed on the + -- body, and lean toward it + if hmag > 1 then + local body = S.body or { yOff = 8 } + local tt = tl / hmag + local need = (S.groundY + body.yOff - p[2] + 0.5 * GRAVITY * tt * tt) / tt + up = up + (need - up) * V_ASSIST + -- and never more than a margin over what this throw needs (OVER_CAP) + if need > 0 then up = math.min(up, need * OVER_CAP) end + up = math.max(U_MIN * 0.6, math.min(U_MAX, up)) + end + S.vel = { hx, up, hz } + estimateT() + paidAndAway() +end + +-- the solved arc, kept for exactly two throwers: the Master Ball (which +-- must not miss) and the pad's A tap (full participation without the +-- dexterity requirement). Flies to the foe's own body centre. +local function launchSolve() + local body = S.body or { r = 10, yOff = 8 } + local E, p = S.enemyPos, S.ballInst.pos + local T = T_REF + S.vel = { (E[1] - p[1]) / T, + (S.groundY + body.yOff - p[2]) / T + 0.5 * GRAVITY * T, + (E[3] - p[3]) / T } + S.T = T + paidAndAway() +end + +local function tapThrow() + if S.shot then launchSolve() end +end + +local function pointerRelease(at) + if not S.grab then return end + S.grab = nil + local vx, vy, n, peak, tail = flickFit(at or S.clock) + if not vx or n < 3 then return end -- a tap, not a throw + local speed = math.sqrt(vx * vx + vy * vy) + -- dead flick: the finger braked before lifting -- the player changed + -- their mind, and the ball drifts back to the hand + if peak and peak > 0 and tail / peak < 0.10 then return end + if speed < S_MIN then return end + if vy / speed > -MIN_UP then return end -- throws point up the screen + local def = S.battle:ballDef(S.ballId) + if def and def.autoCatch then return launchSolve() end + launchVelocity(vx, vy) +end + +-- ------- contact + +local function tierFor(rho, inside) + if not inside then return nil end + if rho <= 0.30 then return "EXCELLENT" end + if rho <= 0.70 then return "GREAT" end + return "NICE" +end + +local function beginSuck(cross) + S.phase = "suck" + S.t = 0 + S.ballInst.pos = cross + S.ballInst.tumble, S.ballInst.tumbleAngle = 0, 0 + S.ballInst.spin, S.ballInst.spinAngle = 0, 0 + S.ballInst.roll, S.ballInst.rollAngle = 0, 0 + -- face the foe: the hinge is at the back, so the mouth gapes at it + local E = S.enemyPos + S.ballInst.yaw = math.atan2(E[1] - cross[1], E[3] - cross[3]) + math.pi + S.ballInst:open() + sound("Ball_Poof") +end + +-- The ball has TOUCHED the Pokemon -- contact is a real collision in +-- world space (see the flight step), so any touch opens the ball; the +-- ring's only job is the GRADE, judged in screen space at this instant: +-- inside the shrinking ring earns the tier, outside is a plain hit. +local function resolveContact(cross) + local shot = S.shot + local cx, cy, outer = ringGeometry(shot) + local rho = ringRho(S.ringT) + local inside = false + local gx, gy = toGB(shot, cross[1], cross[2], cross[3]) + if gx then + local dist = math.sqrt((gx - cx) ^ 2 + (gy - cy) ^ 2) + inside = dist <= rho * outer + 1 + end + S.tier = tierFor(rho, inside) + S.rho = rho + S.hit = true + -- hang the ball where it struck + beginSuck({ cross[1], math.max(S.groundY + Pokeball.R, cross[2]), + cross[3] }) + return true +end + +-- ------- the engine roll, and the queue that finishes the fight + +local function throwMult() + return (S.tier and TIER_MULT[S.tier]) or 1.0 +end + +local function rollCatch() + local b = S.battle + local base = S.safari and (b.safariCatchRate or b.enemy.def.catchRate) + or b.enemy.def.catchRate + local rate = math.min(255, math.floor(base * throwMult())) + local caught, shakes = b:catchAttempt(S.ballId, rate) + S.caught = caught + S.shakes = caught and 3 or (shakes or 0) + emit("battle.ball_thrown", { battle = b, ball = S.ballId, + caught = caught, shakes = shakes }) +end + +-- the camera hold's previous value, owned by whoever engaged first -- the +-- battle-long veil under FULL, or a lone session under CATCH ONLY +local heldStill = nil + +local function releaseMask() + if S and S.artBox and S.artBox.data then + pcall(function() S.artBox.data:release() end) + S.artBox.data = nil + end +end + +local function endSession(keepScene) + if not S then return end + if not keepScene then + BattleCam.still = heldStill or false + heldStill = nil + BattleScene.capture = nil + releaseMask() + S = nil + end +end + +-- the catch: the engine's own captured flow, with the fanfare the anim +-- chain would have supplied. The session stays alive as an epilogue so the +-- ball and the stars remain in shot under the caught text; battle.ended +-- sweeps it away. +local function resolveCaught() + local b = S.battle + b.lastBall = S.ballId + -- the experience context has to exist before storeCaughtMon fires the + -- catch-exp hooks; LetsGo owns the arithmetic + pcall(function() + V.require("LetsGo").noteCatch(b, { + tier = S.tier, + mult = throwMult(), + firstThrow = (b.dramaticShapeThrows or 1) == 1, + safari = S.safari, + }) + end) + b.phase = "messages" + b.afterQueue = "menu" + b:act(function() sound("Caught_Mon") end) + b:say(require("src.core.Strings")("All right!\n%s was\ncaught!", + b.enemy.name)) + b:act(function() b:storeCaughtMon() end) + S.phase = "epilogue" +end + +-- a failed throw -- broke out, or never touched the Pokemon at all. The +-- engine's own miss line for the shake count, then the foe's turn, exactly +-- as throwBall's failure path builds it. +-- +-- Except under FULL, where a wild encounter IS the catch: the foe never +-- takes a turn, and the session stays alive as "await" -- the moment the +-- miss text clears, the next ball is in the hand. Let's Go wilds do not +-- fight back, and the player is never dropped out of throwing mode. +local function resolveFail(shakes) + local b = S.battle + b.lastBall = S.ballId + b.phase = "messages" + b.afterQueue = "menu" + b:say(b:ballMissMessage(shakes)) + if S.fullWild then + S.phase = "await" + S.ballInst.visible = false + S.hit, S.tier, S.planeD = nil, nil, nil + if BattleScene.capture then BattleScene.capture.shrink = nil end + return + end + if S.safari then + b:act(function() b:safariEnemyTurn() end) + else + b:act(function() b:executeAction(b.enemy, b.player, b:enemyAction()) end) + b:act(function() b:endOfTurn() end) + end + endSession() +end + +-- B during the aim. Under FULL it is the encounter's RUN: a Let's Go wild +-- is a catch encounter, so leaving it always works -- the safari's own +-- farewell. Everywhere else it backs out to whatever the session was +-- opened over, refunding a ball the bag menu already consumed. +function CatchThrow.cancel(declined) + if not (S and S.phase == "aim") then return false end + local b = S.battle + if S.fullWild then + sound("Run") + b:say(require("src.core.Strings")("Got away safely!")) + b.result = "run" + b.phase = "messages" + b.afterQueue = "finish" + S.phase = "epilogue" -- the veil holds until battle.ended sweeps + return true + end + if S.consumed and not S.safari then + pcall(function() + require("src.inventory.Bag").add(game().save, S.ballId, 1, game().data) + end) + end + if declined then b.dramaticShapeDeclined = true end + b.phase = "menu" + endSession() + sound("Press_AB") + return true +end + +-- ------- the scene hooks BattleScene consults +-- +-- One table on BattleScene while a session runs: the ball's draw and +-- shadow, the foe's shrink, the hidden player side, and the GB overlay. + +local function sceneDraw(pull) + if not S then return end + S.ballInst:draw(pull) + if S.phase == "suck" then + local E = S.enemyPos + local k = 1 - smoothstep(0, 1, S.t / SUCK_T) + S.ballInst:drawBeam(E[1], S.groundY + CHEST * 0.8, E[3], + 3.2 * k + 0.6, 1, pull) + end +end + +local function sceneCast(shadowMap) + if S then S.ballInst:cast(shadowMap) end +end + +local function sceneSig() + if not S then return "" end + return S.ballInst:signature() .. "," .. S.phase +end + +-- ------- the GB overlay (rings, labels, ball readout) +-- +-- Drawn inside the battle's own 160x144 UI canvas, from the BattleState +-- draw wrap, so it composites exactly where the engine's HUD does and +-- comes out with the same chunky letterboxed pixels. + +local Font = nil +local function font() + if Font ~= nil then return Font or nil end + local ok, F = pcall(require, "src.render.Font") + Font = ok and F or false + return Font or nil +end + +local function label(str, x, y, align) + local F = font() + if not F then return end + local g = love.graphics + local w = F.width(str) + 4 + local px = align == "right" and (x - w) or + align == "center" and math.floor(x - w / 2) or x + g.setColor(0.06, 0.05, 0.09, 0.75) + g.rectangle("fill", px, y, w, 11) + g.setColor(0.93, 0.94, 0.90, 1) + g.rectangle("fill", px + 1, y + 1, w - 2, 9) + g.setColor(0, 0, 0, 1) + F.draw(str, px + 2, y + 2) + g.setColor(1, 1, 1, 1) +end + +-- a tiny ball glyph for the readout +local BALL_TOP = { + POKE_BALL = { 0.86, 0.16, 0.16 }, GREAT_BALL = { 0.25, 0.45, 0.88 }, + ULTRA_BALL = { 0.22, 0.22, 0.26 }, MASTER_BALL = { 0.48, 0.22, 0.66 }, + SAFARI_BALL = { 0.47, 0.52, 0.26 }, +} + +local function ballGlyph(x, y, id) + local g = love.graphics + local top = BALL_TOP[id] or BALL_TOP.POKE_BALL + g.setColor(0.93, 0.94, 0.90, 1) + g.circle("fill", x, y, 4) + g.setColor(top[1], top[2], top[3], 1) + g.arc("fill", x, y, 4, math.pi, 2 * math.pi) + g.setColor(0.08, 0.08, 0.09, 1) + g.setLineWidth(1) + g.line(x - 4, y, x + 4, y) + g.circle("line", x, y, 4) + g.setColor(1, 1, 1, 1) +end + +-- how sure the ring should look: the Gen 1 odds, ball and status included, +-- but never the throw -- a colour that jittered with your own inputs would +-- be unreadable (and is why the reference game excludes it too) +local function ringColor() + local b = S.battle + local ok, p = pcall(function() + local def = b:ballDef(S.ballId) + if def and def.autoCatch then return 1 end + local rate = S.safari and (b.safariCatchRate or 0) or b.enemy.def.catchRate + local randMax = (def and def.randMax) or 255 + return math.max(0.05, math.min(1, (rate + 1) / (randMax + 1))) + end) + if not ok then p = 0.5 end + return 1 - p * 0.75, 0.25 + p * 0.7, 0.28, p +end + +local function drawGB(battle) + if not S or S.battle ~= battle then return end + local shot = OB().shot() + if not shot then return end + S.shot = shot + 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) + g.setLineWidth(1) + -- outer: fixed, white, the hit area + g.setColor(1, 1, 1, 0.85) + g.circle("line", cx, cy, outer) + -- inner: the timing ring, coloured by the odds + local r, gr, bl = ringColor() + g.setColor(r, gr, bl, 0.95) + g.circle("line", cx, cy, math.max(1.5, rho * outer)) + end + + if S.phase == "aim" then + -- the ball readout: which ball, how many left + local count + if S.safari then + count = battle.safari and battle.safari.balls or 0 + else + count = (game().save.inventory[S.ballId] or 0) + + ((S.consumed and not S.safari) and 1 or 0) + end + local name = (game().data.items[S.ballId] + and game().data.items[S.ballId].name) or S.ballId + -- along the bottom edge, out of the throwing lane: with the empty + -- text box gone the whole middle of the frame is the wind-up, and + -- chrome parked in it would be something to drag around + -- " BALL" is dropped from the name: the glyph beside it is already a + -- ball, in that tier's own colours, and the full string is wide + -- 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) + -- 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 + -- slow at a quarter wind, bright gold and quick at the cap -- the cap + -- is the only wind that curves, so it has to be unmistakable + if S.spinLevel > 0.12 then + local bx, by = S.handGB[1], S.handGB[2] + local orbit = math.max(6, (shot.playerSpan or 16) * 0.2) + local lvl = S.spinLevel + if S.spinArmed then + g.setColor(1, 0.9, 0.35, 1) + else + g.setColor(1, 1, 0.75, 0.25 + lvl * 0.55) + end + for i = 0, 2 do + local a = S.clock * (3 + 8 * lvl) * S.spinSign + i * (2 * math.pi / 3) + g.circle("fill", bx + math.cos(a) * orbit, + by + math.sin(a) * orbit * 0.45, S.spinArmed and 1.5 or 1) + end + end + end + + -- the grade, splashed at the ring for a beat after contact + if S.tier and S.tierSplash and S.tierSplash > 0 then + local cx, cy = ringGeometry(shot) + label(S.tier .. "!", cx, math.max(8, cy - 20), "center") + end + + g.pop() +end + +-- ------- ball switching (the FULL entry owns its ball choice) + +local SWITCHABLE = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" } + +local function ownedBalls() + local inv = game().save.inventory + local out = {} + for _, id in ipairs(SWITCHABLE) do + if (inv[id] or 0) > 0 then out[#out + 1] = id end + end + return out +end + +local function switchBall(dir) + if not (S and S.canSwitch and S.phase == "aim") then return end + local owned = ownedBalls() + if #owned == 0 then return end + local at = 1 + for i, id in ipairs(owned) do + if id == S.ballId then at = i break end + end + S.ballId = owned[((at - 1 + dir) % #owned) + 1] + S.ballInst.ball = S.ballId + sound("Press_AB") +end + +CatchThrow.pickBall = function() + local owned = ownedBalls() + for _, id in ipairs(owned) do + if id == CatchThrow.lastBall then return id end + end + 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 +-- session OR by the battle-long veil below. +local function installScene() + if not BattleScene.capture then + if heldStill == nil then heldStill = BattleCam.still end + BattleCam.still = true + end + BattleScene.capture = { + hidePlayer = true, + rig = captureRig, + draw = sceneDraw, + cast = sceneCast, + sig = sceneSig, + drawGB = drawGB, + shrink = nil, + } +end + +-- The FULL veil: installed at battle.started for a Let's Go wild, before +-- any session exists, so the whole encounter -- the wipe, the "Wild X +-- appeared!" line, every beat between throws -- plays from the head-on +-- seat with the player's side out of the shot. The player's Pokemon is +-- NEVER shown; the encounter IS the catch. +function CatchThrow.veil(battle) + installScene() +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), +-- 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() + assert(arena and shot and shot.eye, "no staged shot to capture in") + local ball = Pokeball.new(ballId) + S = { + battle = battle, + ballId = ballId, + safari = opts.safari or false, + fullWild = opts.fullWild or false, + empty = empty, + consumed = opts.consumed or false, + canSwitch = (opts.canSwitch and not empty) or false, + declinable = opts.declinable ~= false, + phase = "aim", + clock = 0, t = 0, ringT = 0, + samples = {}, grab = nil, + spinLevel = 0, spinArmed = false, spinSign = 1, + stickHeld = false, stickSamples = {}, + ballInst = ball, + enemyPos = { arena.enemy[1], groundY, arena.enemy[2] }, + playerPos = { arena.player[1], groundY, arena.player[2] }, + groundY = groundY, + shot = shot, + handGB = { 80, 110 }, + } + ball.pos = handPos() + ball.visible = not empty + S.body = { r = 10, yOff = 8, hh = 8 } -- until a measurement lands + measureBody() + end) + if not ok or not S then + S = nil + return false + end + battle.phase = CatchThrow.PHASE + 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 + -- and lens come back when the hold releases. + installScene() + 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. +-- +-- 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 + 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 + S.samples, S.grab, S.spinPrev = {}, nil, nil + S.spinLevel, S.spinArmed, S.spinSign = 0, false, 1 + S.stickHeld, S.stickSamples = false, {} + S.consumed = false + S.hit, S.tier, S.rho, S.caught, S.shakes, S.planeD = nil, nil, nil, nil, + nil, nil + S.tFly, S.tierSplash = 0, nil +end + +-- battle.ended sweeps the epilogue, the veil, and any half-open session: +-- a battle torn down by a script must never leave the camera held +function CatchThrow.onBattleEnded() + releaseMask() + if heldStill ~= nil or S ~= nil or BattleScene.capture ~= nil then + BattleCam.still = heldStill or false + end + heldStill = nil + BattleScene.capture = nil + S = nil +end + +-- ------- input entry points (installed once, from LetsGo.install) + +local installed = false + +-- window coordinates (LOVE units) -> GB frame pixels +local function winToGB(x, y) + local shot = S and S.shot + if not shot then return nil end + local uw, uh = love.graphics.getDimensions() + if not (uw > 0 and uh > 0) then return nil end + local px = x * shot.pw / uw + local py = y * shot.ph / uh + 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" and not S.empty +end + +local function pointer(kind, x, y, id) + local gx, gy = winToGB(x, y) + if not gx then return end + if kind == "press" and not S.grab then + S.grab = { id = id } + S.samples = {} + S.spinPrev = nil + pushSample(gx, gy) + elseif S.grab and S.grab.id == id then + pushSample(gx, gy) + feedSpin(gx, gy) + if kind == "release" then pointerRelease(S.clock) end + 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 + -- these are the OUTER ones and the aim owns the button first. Bare + -- motion is not claimed -- CamControl's battle steer already stands + -- down while the capture holds the camera (BattleCam.steerable). + do + local inner = love.mousepressed + love.mousepressed = function(x, y, button, istouch, presses) + if aiming() and not istouch and button == 1 then + pointer("press", x, y, "mouse") + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + do + local inner = love.mousemoved + love.mousemoved = function(x, y, dx, dy, istouch) + if aiming() and not istouch and S.grab and S.grab.id == "mouse" then + pointer("move", x, y, "mouse") + return + end + if inner then return inner(x, y, dx, dy, istouch) end + end + end + do + local inner = love.mousereleased + love.mousereleased = function(x, y, button, istouch, presses) + if aiming() and not istouch and button == 1 + and S.grab and S.grab.id == "mouse" then + pointer("release", x, y, "mouse") + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + + -- ------- touch + -- + -- A finger on open screen is the throw; fingers on the overlay's d-pad + -- and buttons flow to TouchControls untouched, so B still backs out on + -- a phone. One finger owns the ball at a time, the FirstPerson rule. + local TouchControls = require("src.core.TouchControls") + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if aiming() then + local onControl = nil + pcall(function() onControl = TouchControls:hitTest(x, y) end) + if not onControl and not S.grab then + pointer("press", x, y, id) + return + end + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + if aiming() and S.grab and S.grab.id == id then + pointer("move", x, y, id) + return + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if aiming() and S.grab and S.grab.id == id then + pointer("release", x, y, id) + return + end + return inner(self, id, x, y) + end + end +end + +-- ------- the stick, polled +-- +-- FirstPerson's always-on wrap records the right stick whatever the mode, +-- so the session reads it rather than claiming a seam of its own. The +-- stick plays "virtual finger": deflection stands a finger at centre + +-- stick * STICK_R, circling winds the spin, and a hard upward flick of +-- the stick is the throw -- strength from the flick's own velocity. +local function stickInput(dt) + local FirstPerson = V.require("FirstPerson") + local sx = FirstPerson.stickX and FirstPerson.stickX() or 0 + local sy = FirstPerson.stickY and FirstPerson.stickY() or 0 + local mag = math.sqrt(sx * sx + sy * sy) + + local st = S.stickSamples + st[#st + 1] = { x = sx, y = sy, t = S.clock } + if #st > 16 then table.remove(st, 1) end + + if mag > STICK_GRAB then + if not S.stickHeld then + S.stickHeld = true + if not S.grab then + S.grab = { id = "stick" } + S.samples = {} + S.spinPrev = nil + end + end + if S.grab and S.grab.id == "stick" then + local gx = S.handGB[1] + sx * STICK_R + local gy = S.handGB[2] + sy * STICK_R + pushSample(gx, gy) + feedSpin(gx, gy) + -- the flick: stick velocity over the last few samples, upward and + -- fast. Measured in stick units so a worn pad and a fresh one + -- answer alike after their deadzones. + local n = #st + if n >= 3 then + local a, b = st[n - 2], st[n] + local dt2 = b.t - a.t + if dt2 > 1e-4 then + local vy = (b.y - a.y) / dt2 + if vy < -STICK_VEL and sy < -0.45 then + -- hand the same release path the pointer takes; the samples + -- already trace the gesture in GB pixels + pointerRelease(S.clock) + S.stickHeld = false + end + end + end + end + elseif S.stickHeld then + S.stickHeld = false + if S.grab and S.grab.id == "stick" then + -- stick returned to centre without the flick: let the fit decide + -- whether the travel back was itself a throw (it usually is not) + pointerRelease(S.clock) + end + end +end + +-- ------- per-frame drive (called from LetsGo.update, before the battle +-- scene renders, so the ball this frame draws is the ball this frame) + +function CatchThrow.update(dt) + if not S then return end + local b = S.battle + S.clock = S.clock + dt + S.ringT = S.ringT + dt + S.ballInst:update(dt) + if S.tierSplash then S.tierSplash = S.tierSplash - dt end + -- the box is back by default and taken away only by the aim branch, so + -- the first frame with something to say has somewhere to say it + if BattleScene.capture then BattleScene.capture.hideTextBox = false end + + -- the battle went away under us (forced finish, a script): stand down + if S.phase ~= "epilogue" then + local ok, gone = pcall(function() + return b.result ~= nil and b.result ~= false + end) + if ok and gone then + CatchThrow.onBattleEnded() + return + end + end + + -- the staged shot for this frame's reasoning: last frame's render. + -- Losing it (a lost context, a broken arena) abandons the session + -- rather than playing blind. + local shot = OB().shot() + if shot then + S.shot = shot + S.lostShot = 0 + else + S.lostShot = (S.lostShot or 0) + dt + if S.lostShot > 1.0 and S.phase == "aim" then + CatchThrow.cancel(true) + return + end + 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 + -- foe that never answers + if not S.bodyMeasured and (S.artTries or 0) < 30 then + S.artTries = (S.artTries or 0) + 1 + measureBody() + end + -- The held ball is DRAGGED, riding directly under the finger with no + -- fence around it (the reference model: the ball follows the hand, + -- and the throw will be the hand's own velocity). It moves in the + -- CAMERA-FACING PLANE through its hover -- screen right is the eye's + -- right, screen up is the eye's own tilted up -- so every part of + -- the frame maps to somewhere the ball can be: dragging low brings + -- it down and toward the seat rather than jamming it into the floor. + -- The floor is the one true limit, and it is the world's, not a box. + local home = handPos() + local bob = math.sin(S.clock * 2.2) * 0.35 + local target = { home[1], home[2] + bob, home[3] } + if S.grab and #S.samples > 0 then + -- straight down the finger's own ray, at the hand's reach: the ball + -- is wherever the pointer is, over the WHOLE frame, with no plane + -- and no box to run into. + -- + -- The one limit is the world's own floor, and it is not a clamp: + -- drag low enough that the ray would put the ball underground and + -- the ball is pulled IN toward the eye instead, which is how a + -- hand holding it low actually looks. So the bottom of the screen + -- is reachable everywhere -- the ball simply comes nearer as it + -- goes down, rather than stopping dead at the grass. + local last = S.samples[#S.samples] + local e = S.shot.eye + local dx, dy, dz = rayThrough(last.x, last.y) + if dx then + local reach = math.sqrt((home[1] - e[1]) ^ 2 + (home[2] - e[2]) ^ 2 + + (home[3] - e[3]) ^ 2) + if dy < -1e-6 then + local floor = (e[2] - (S.groundY + Pokeball.R * 1.4)) / -dy + reach = math.min(reach, math.max(Pokeball.R * 3, floor)) + end + target = { e[1] + dx * reach, e[2] + dy * reach, e[3] + dz * reach } + end + end + -- direct under the finger, a soft drift when coming home + local ease = S.grab and math.min(1, dt * 30) or math.min(1, dt * 9) + local bp = S.ballInst.pos + bp[1] = bp[1] + (target[1] - bp[1]) * ease + bp[2] = bp[2] + (target[2] - bp[2]) * ease + bp[3] = bp[3] + (target[3] - bp[3]) * ease + -- face the seat, so the wind-up's roll reads as the ball turning + -- clockwise/counter-clockwise to the player watching it + S.ballInst.yaw = eyeYaw(bp[1], bp[3]) + local gx, gy = toGB(S.shot, bp[1], bp[2], bp[3]) + if gx then S.handGB = { gx, gy } end + + -- the wind-up meter: bleeds once the hand pauses, and the ball's + -- visible roll IS the meter -- faster the more it is wound + if S.spinLevel > 0 and S.clock - (S.spinT or 0) > SPIN_IDLE then + S.spinLevel = math.max(0, S.spinLevel - SPIN_DECAY * dt) + end + if S.spinArmed and S.spinLevel < DISARM_AT then S.spinArmed = false end + S.ballInst.roll = -S.spinSign * S.spinLevel * ROLL_MAX + -- the empty text box is off the frame while aiming, which is what + -- gives the drag the bottom third of the screen to wind up in + if BattleScene.capture then BattleScene.capture.hideTextBox = true end + + stickInput(dt) + -- 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 -- 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 rearm(CatchThrow.pickBall()) end + return + end + + if S.phase == "flight" then + S.acc = S.acc + dt + local E = S.enemyPos + -- The foe's GEOMETRY, not a blob. A pic foe is judged against its own + -- art: crossing the card's plane samples the sprite's opaque pixels + -- (dilated by the ball's radius and a mercy margin), so a ball through + -- the gap under a wing flies on and one that clips the wing connects. + -- A model foe is an ellipsoid at its measured height and footprint; + -- the default torso covers a foe that never answered. + local body = S.body or { r = 10, yOff = 8, hh = 8 } + local box = S.artBox + local fx, fz, rx, rz = camBasis(S.shot) + local kPic = 16 / 56 -- world px per pic px + local dilate = (Pokeball.R * 1.6 + 2) / kPic + local sr = body.r * 1.3 + Pokeball.R + 1.5 -- ellipsoid semi-axes + local sh = (body.hh or body.r) + Pokeball.R + 1 + while S.acc >= H_STEP do + S.acc = S.acc - H_STEP + local p, v = S.ballInst.pos, S.vel + local ax, ay, az = 0, -GRAVITY, 0 + if S.spinArmed then + local w = smoothstep(CURVE_RAMP0, CURVE_RAMP1, S.tFly / S.T) + ax = ax + rx * CURVE_GAIN * S.spinSign * w + az = az + rz * CURVE_GAIN * S.spinSign * w + end + v[1], v[2], v[3] = v[1] + ax * H_STEP, v[2] + ay * H_STEP, + v[3] + az * H_STEP + p[1] = p[1] + v[1] * H_STEP + p[2] = p[2] + v[2] * H_STEP + p[3] = p[3] + v[3] * H_STEP + S.tFly = S.tFly + H_STEP + + if not S.hit then + local contact = false + if box and box.data then + -- the card's plane through the foe's cell, crossed this step? + local d1 = (p[1] - E[1]) * fx + (p[3] - E[3]) * fz + if (S.planeD or -1) < 0 and d1 >= 0 then + -- where the ball stands ON the art, in pic pixels + local ux = (p[1] - E[1]) * rx + (p[3] - E[3]) * rz + local px = box.ax + ux / kPic + local py = box.ay - (p[2] - S.groundY) / kPic + contact = maskHit(box.data, px, py, dilate) + end + S.planeD = d1 + else + local dx = (p[1] - E[1]) / sr + local dy = (p[2] - (S.groundY + body.yOff)) / sh + local dz = (p[3] - E[3]) / sr + contact = dx * dx + dy * dy + dz * dz <= 1 + end + if contact then + resolveContact({ p[1], p[2], p[3] }) + S.tierSplash = S.tier and 1.0 or nil + return + end + end + + -- short or wide: the ground takes it, with a couple of tired hops + if p[2] <= S.groundY + Pokeball.R and v[2] < 0 then + p[2] = S.groundY + Pokeball.R + v[2] = -v[2] * 0.35 + v[1], v[3] = v[1] * 0.55, v[3] * 0.55 + S.ballInst.tumble = S.ballInst.tumble * 0.5 + if math.abs(v[2]) < 12 then v[2] = 0 end + end + end + if S.tFly > S.T + 1.2 then + S.ballInst.visible = false + resolveFail(0) -- "You missed the POKéMON!" + end + return + end + + if S.phase == "suck" then + S.t = S.t + dt + local k = 1 - smoothstep(0, 1, S.t / SUCK_T) + BattleScene.capture.shrink = math.max(0.02, k) + if S.t >= SUCK_T then + S.ballInst:close() + S.phase = "drop" + S.t = 0 + -- the roll happens NOW, so the wobble count is honest + rollCatch() + local p = S.ballInst.pos + S.dropFrom = { p[1], p[2], p[3] } + -- land a step toward the camera from the foe's feet + local fx, fz = camBasis(S.shot) + S.dropTo = { S.enemyPos[1] - fx * 5, S.groundY + Pokeball.R, + S.enemyPos[3] - fz * 5 } + end + return + end + + if S.phase == "drop" then + S.t = S.t + dt + local u = math.min(1, S.t / DROP_T) + local p0, p1 = S.dropFrom, S.dropTo + -- a dropped arc: linear across, gravity-shaped down, one bounce + local h = (1 - u) * (p0[2] - p1[2]) + local bounce = 0 + if u > 0.82 then + bounce = math.abs(math.sin((u - 0.82) / 0.18 * math.pi)) * 1.2 * (1 - u) + end + S.ballInst.pos = { p0[1] + (p1[1] - p0[1]) * u, + p1[2] + h * (1 - u * 0.4) + bounce, + p0[3] + (p1[3] - p0[3]) * u } + if u >= 1 then + S.ballInst.pos = { p1[1], p1[2], p1[3] } + S.phase = "wobble" + S.t = -0.35 -- a beat before the first rock + S.shakeIx = 0 + sound("Tink") + end + return + end + + if S.phase == "wobble" then + S.t = S.t + dt + if S.ballInst:busy() then return end + if S.t < SHAKE_GAP then return end + if S.shakeIx < S.shakes then + S.shakeIx = S.shakeIx + 1 + S.t = 0 + S.ballInst:rock(S.shakeIx % 2 == 0 and 1 or -1) + sound("Tink") + elseif S.caught then + S.phase = "clicked" + S.t = 0 + S.ballInst:catchClick() + else + -- the breakout: the ball bursts, the foe pours back out + S.phase = "burst" + S.t = 0 + S.ballInst:burst() + sound("Ball_Poof") + end + return + end + + if S.phase == "clicked" then + S.t = S.t + dt + if S.t >= 0.9 then resolveCaught() end + return + end + + if S.phase == "burst" then + S.t = S.t + dt + local k = smoothstep(0, 1, S.t / 0.3) + BattleScene.capture.shrink = k >= 0.98 and nil or math.max(0.02, k) + if S.t >= 0.55 then + BattleScene.capture.shrink = nil + S.ballInst.visible = false + resolveFail(S.shakes) + end + return + end + + -- epilogue: the ball rests in shot while the caught text plays; the + -- stars fade on their own, and battle.ended clears the table +end + +return CatchThrow diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..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 @@ -812,18 +848,25 @@ function ChunkMesher.build(map, bodyOnly, masks, split) return sink.finish(), waterSink and waterSink.finish() or nil end -local function quadsMesh(quads) +local function quadsMesh(quads, grass) if #quads == 0 then return nil end local verts, indices, n = {}, {}, 0 for _, q in ipairs(quads) do for i = 1, 4 do local c = q[i] local uv = q.uv and q.uv[i] or { q.u, q.v } - verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade } + local v = { c[1], c[2], c[3], uv[1], uv[2], q.shade } + if grass then + v[7], v[8], v[9], v[10] = q.sway or 0, q.cx or 0, + q.cz or 0, + q.firefly and 2 or (q.leaf and 1 or 0) + end + verts[#verts + 1] = v end Voxel3D.pushQuad(indices, n) n = n + 1 end + if grass then return Voxel3D.newGrassMesh(verts, indices) end return Voxel3D.newMesh(verts, indices) end @@ -832,7 +875,7 @@ end -- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary -- terrain could never do this). local function buildGrassMesh(map) - return quadsMesh(Structures.forMap(map).grassQuads) + return quadsMesh(Structures.forMap(map).grassQuads, true) end -- The flower billboards as their own mesh, for the same reason as the diff --git a/lib/ExpPanel.lua b/lib/ExpPanel.lua new file mode 100644 index 0000000..c967027 --- /dev/null +++ b/lib/ExpPanel.lua @@ -0,0 +1,98 @@ +-- LET'S GO: the whole party's experience, on one card. +-- +-- Sharing experience to everybody has a cost the original game never had +-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every +-- knockout, each needing its own press. That is the same information the +-- player wanted, delivered in the most tiring possible way -- and it is +-- worse than a wall of text, because the numbers arrive one at a time so +-- the one thing a shared payout is FOR (comparing them: the little one +-- gained five times what the big one did) can never be seen at once. +-- +-- So the per-Pokemon lines are suppressed and this card is shown in their +-- place: one box, one press, every gain side by side, with a level-up +-- called out on the row it happened to. What the card cannot cover still +-- plays as it always did -- "X grew to level 6!", the stats window, and +-- any move learned -- because those are events, not a tally. +-- +-- Drawn with the engine's own font and box, in the GB's own frame, so it +-- sits in a staged 3D battle exactly like every other battle panel. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ExpPanel = {} +ExpPanel.__index = ExpPanel + +local Font = nil +local function font() + if Font ~= nil then return Font or nil end + local ok, F = pcall(require, "src.render.Font") + Font = ok and F or false + return Font or nil +end + +-- the GB frame, in 8-pixel tiles +local TILE = 8 +local COLS, ROWS = 20, 18 +local ROW_H = 12 -- pixels between listed Pokemon + +-- `rows` is filled by the award loop and read HERE, at draw time: the +-- loop runs to completion long before the battle queue reaches this +-- panel, so the table is always complete by the time it is shown. +function ExpPanel.new(game, rows) + return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel) +end + +function ExpPanel:update(dt) + self.t = (self.t or 0) + (dt or 0) + local input = self.game and self.game.input + if not input then return end + -- a beat of deafness: the press that dismissed whatever came before + -- must not dismiss this card in the same breath + if self.t < 0.12 then return end + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +function ExpPanel:draw() + local F = font() + if not F then return end + local rows = self.rows or {} + local n = #rows + if n == 0 then return end + + -- bottom-anchored and only as tall as it needs to be, so a two-Pokemon + -- party does not black out the fight behind it + local th = 3 + math.ceil(n * ROW_H / TILE) + th = math.min(th, ROWS - 1) + local ty = ROWS - th + F.drawBox(0, ty, COLS, th) + + love.graphics.setColor(0, 0, 0, 1) + local x0 = TILE + local y0 = (ty + 1) * TILE + 2 + F.draw("EXP GAINED", x0, y0) + + for i, r in ipairs(rows) do + local y = y0 + ROW_H + (i - 1) * ROW_H + if y > (ROWS - 1) * TILE then break end + local mon = r.mon + local name = mon.nickname + or (self.game.data.pokemon[mon.species] or {}).name + or tostring(mon.species) + F.draw(name, x0, y) + -- a level-up is called out where it happened rather than left to the + -- message that follows, so the card reads as the whole story + if (r.to or 0) > (r.from or 0) then + local up = ("L%d"):format(r.to) + F.draw(up, 108 - F.width(up), y) + end + local amt = ("+%d"):format(r.gained or 0) + F.draw(amt, 152 - F.width(amt), y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return ExpPanel diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua new file mode 100644 index 0000000..e2075ef --- /dev/null +++ b/lib/LetsGo.lua @@ -0,0 +1,460 @@ +-- LET'S GO: the row, the modes, and every engine seam the capture game +-- stands on. +-- +-- Three rungs: +-- +-- OFF nothing changes. The default, and what an unrecognised +-- stored value falls back to. +-- FULL the whole Let's Go treatment. A wild encounter opens +-- STRAIGHT into capture mode (B backs out to the classic +-- menu for anyone who came to fight), Poke/Great/Ultra +-- Balls are half price at every mart, and EXPERIENCE works +-- the way that game's does: every healthy party member +-- gains from every catch AND every trainer knockout, each +-- measured against its own level. A catch adds the throw +-- stack on top -- grade, first throw, new species, combo. +-- CATCH ONLY the fights are untouched and the shops are untouched; +-- the one change is that throwing a ball -- from the bag, +-- or a SAFARI BALL from the safari menu -- runs the throw +-- minigame instead of the automatic toss. The minigame's +-- grade still folds into the Gen 1 catch roll (a good +-- throw should matter or the ring is a lie), but nothing +-- outside the throw changes. +-- +-- The capture game itself lives in lib/CatchThrow.lua and the ball it +-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting, +-- the two BattleState wraps that intercept a ball being thrown, the +-- auto-entry tick for FULL, the price patch, and the experience hooks. +-- +-- ------- where the minigame declines to run +-- +-- The throw is a 3D scene: it needs the staged battle standing (the +-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by +-- default), a driver with a depth buffer, and a flat screen (the VR seat +-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL +-- switched off, a headless driver, a headset -- the ball quietly takes +-- the engine's own toss, which is exactly what the mod's "declines +-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the +-- old man's demo keep the vanilla path on purpose: those branches ARE +-- their behaviour. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local Voxel3D = V.require("Voxel3D") +local CatchThrow = V.require("CatchThrow") + +local LetsGo = {} + +LetsGo.KEY = "letsgo" +LetsGo.LABEL = "LET'S GO" + +-- `false` first: the default, and the fallback for a stored value from +-- some other version of this ladder +LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL, + { false, "full", "catching" }, + { "OFF", "FULL", "CATCH ONLY" }) + +-- false | "full" | "catching" +function LetsGo.mode() + return LetsGo.setting:get() +end + +local function game() return require("src.core.Game") end + +-- ------- half-price balls (FULL) +-- +-- Prices are live data (game.data.items[id].price) and every reader -- +-- the buy list, the affordability check, the quantity box -- reads them +-- per use, so patching the table IS the feature. Applied and reverted on +-- the option's edge, polled from the tick because the row, the manager's +-- page and a loaded save can all move it and none of them announces to +-- us. The sell price follows automatically (the mart pays half of list), +-- which is coherent: cheaper balls are worth less back too. +local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } +local fullPrices = nil -- originals while halved, or nil + +local function applyPrices() + local g = game() + local items = g and g.data and g.data.items + if not items then return end + local wantHalf = LetsGo.mode() == "full" + if wantHalf and not fullPrices then + fullPrices = {} + for _, id in ipairs(PRICED) do + local def = items[id] + if def and def.price then + fullPrices[id] = def.price + def.price = math.floor(def.price / 2) + end + end + elseif not wantHalf and fullPrices then + for id, price in pairs(fullPrices) do + if items[id] then items[id].price = price end + end + fullPrices = nil + end +end + +-- ------- whether a throw can be the minigame + +local function vrOn() + local ok, vr = pcall(V.require, "VR") + 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 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 + local ok, shot = pcall(function() + return V.require("OverworldBattle").shot() + end) + return (ok and shot) and true or false +end + +-- A Let's Go wild: the encounters FULL owns outright. In these the foe +-- never takes a turn, the player's Pokemon is never sent out or shown, +-- B runs (and always escapes), and the encounter lives in throw mode +-- from the wipe to the last message. +function LetsGo.fullWild(battle) + return LetsGo.mode() == "full" and battle and battle.kind == "wild" + and not LetsGo.scripted(battle) + and not (battle.safari or battle.ghost or battle.noCatch) + and true or false +end + +-- ------- the experience stack (FULL) +-- +-- Let's Go pays a catch like a knockout, through the Gen VII scaled +-- formula -- every party member paid against its OWN level -- times the +-- catch bonuses. Three engine hooks carry it: +-- +-- battle.catch_exp "does a catch pay at all" -- yes, under FULL +-- battle.exp_award the distribution: every healthy party member its +-- own full share, no participant split +-- exp.gain the amount: the scaled formula times the bonus +-- stack, in place of floor(b*L/7) +-- +-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first +-- ball of the encounter 1.5, species new to the dex 1.1, and the catch +-- combo tier. Traded 1.5 still rides through the engine's own flag. +local expCtx = nil -- {battle, mult} while a Let's Go catch pays out +local granting = nil -- set across the applyShare loop for exp.gain + +local function comboMult(n) + if n <= 10 then return 1.1 end + if n <= 20 then return 1.5 end + if n <= 30 then return 2.0 end + if n <= 40 then return 2.5 end + return 3.0 +end + +-- the catch combo, persisted with the save (mod.save rides save.modData): +-- catching the same species again extends it, anything else restarts it +local function bumpCombo(species) + local ms = V.mod and V.mod.save + local combo = { species = species, count = 1 } + if ms then + local ok, held = pcall(ms.get, ms, "letsgoCombo") + if ok and type(held) == "table" and held.species == species then + combo.count = (tonumber(held.count) or 0) + 1 + end + pcall(ms.set, ms, "letsgoCombo", combo) + end + return combo.count +end + +function LetsGo.combo() + local ms = V.mod and V.mod.save + if not ms then return nil end + local ok, held = pcall(ms.get, ms, "letsgoCombo") + return ok and type(held) == "table" and held or nil +end + +-- Called by CatchThrow the moment a capture resolves as caught, BEFORE +-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is +-- still answerable, and the exp hooks fire inside storeCaughtMon. +function LetsGo.noteCatch(battle, info) + local species = battle.enemy and battle.enemy.mon + and battle.enemy.mon.species + local chain = species and bumpCombo(species) or 1 + if LetsGo.mode() ~= "full" then return end + local mult = info.mult or 1 + if info.firstThrow then mult = mult * 1.5 end + local dex = game().save and game().save.pokedex + if dex and species and not dex.owned[species] then mult = mult * 1.1 end + mult = mult * comboMult(chain) + expCtx = { battle = battle, mult = mult } +end + +-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own +-- level, +1, then the traded boost and (for a catch) the bonus stack. +-- `s`, the split divisor, is 1 -- the award loop below hands every mon a +-- full share rather than a share of one. +-- +-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is +-- absent from the catch-side write-ups of this formula for the simple +-- reason that a caught Pokemon is always wild, so it is always 1 there -- +-- which is also why adding it leaves every catch payout exactly where it +-- was, verified against the published table in the suite. +local function scaledGain(c, mult) + local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50 + local L = c.level or 1 + local Lp = (c.mon and c.mon.level) or L + local a = c.isTrainer and 1.5 or 1 + local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5 + local exp = math.floor(math.floor(a * b * L / 5) * scale + 1) + if c.traded then exp = math.floor(exp * 1.5) end + return math.max(1, math.floor(exp * (mult or 1))) +end + +LetsGo._scaledGain = scaledGain -- named for the suite +LetsGo._comboMult = comboMult + +-- ------- FULL's auto-entry +-- +-- The moment a wild battle's menu opens under FULL, capture mode opens +-- over it, with the last ball the player threw (or the first ball in the +-- bag). B backs out to the classic menu and stays out for that battle -- +-- the bag's own ball route still re-enters the throw. +local function autoEnter() + if LetsGo.mode() ~= "full" then return end + if CatchThrow.active() then return end + local ok, battle = pcall(function() + return V.require("OverworldBattle").battle() + end) + if not (ok and battle) then return end + local g = game() + if not (g.stack and g.stack:top() == battle) then return end + if battle.phase ~= "menu" then return end + if battle.safari then return end -- the safari menu is already a + -- catch menu; its BALL row enters + if battle.dramaticShapeDeclined then return end + if not LetsGo.wantsMinigame(battle) then return end + local ball = CatchThrow.pickBall() + 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 = full }) +end + +-- ------- per frame, from the voxel pipeline's update hook +-- +-- BEFORE OverworldBattle.update on the same tick, so the ball pose this +-- frame computes is the ball the scene render a moment later draws. +function LetsGo.update(dt) + applyPrices() + CatchThrow.update(dt) + autoEnter() +end + +-- ------- install: the two throw seams, the hooks, the input +-- +-- Installed from main.lua AFTER every other input seam, so the capture's +-- pointer wraps sit outside them all while it aims. +local installed = false + +function LetsGo.install() + if installed then return end + installed = true + + local mod = V.mod + local BattleState = require("src.battle.BattleState") + + if not BattleState.dramaticShapeLetsGoHook then + -- The bag's ball route: BagMenu has already consumed the ball and + -- closed itself when this is called, so a session here owns a paid + -- ball (cancel refunds it). Vanilla path untouched whenever the + -- minigame cannot or should not run. + local innerThrow = BattleState.throwBall + function BattleState:throwBall(ball) + if LetsGo.wantsMinigame(self) then + if CatchThrow.begin(self, ball, { + consumed = true, fullWild = LetsGo.fullWild(self), + }) then return end + end + return innerThrow(self, ball) + end + + -- The safari menu's BALL row: same interception, safari flavour -- + -- the ball count and the flee check belong to the safari turn, and + -- CatchThrow hands back to safariEnemyTurn on a failure. + local innerSafari = BattleState.safariAction + function BattleState:safariAction(choice) + if choice == "ball" and LetsGo.wantsMinigame(self) + and self.safari and self.safari.balls > 0 then + if CatchThrow.begin(self, "SAFARI_BALL", + { consumed = false, safari = true }) then + return + end + end + return innerSafari(self, choice) + end + + BattleState.dramaticShapeLetsGoHook = true + 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" + and not LetsGo.scripted(ctx.battle) then + return true + end + return next_(ctx) + end) + + -- ------- the Let's Go distribution: every healthy party member, in full + -- + -- The engine's own rule is that only the Pokemon that FOUGHT are paid, + -- and they split one award between them; EXP.ALL exists to soften that. + -- Let's Go deletes the whole arrangement -- everybody gains from + -- everything, which is why that game ships no EXP.ALL at all -- and + -- each one is measured against its OWN level, so the low member of a + -- party pulls several times what the high one does from the same + -- knockout. + -- + -- Two ways in. A CATCH arrives with a bonus stack attached (throw + -- grade, first ball, new species, combo) which `expCtx` carries. A + -- KNOCKOUT under FULL takes the same distribution with no stack -- + -- those bonuses are rewards for the throw, and there was no throw. + -- + -- Everything else -- CATCH ONLY, the row switched off, another mod's + -- battle -- falls through to the engine's own split untouched. + -- ------- and it is announced ONCE, not once per Pokemon + -- + -- Six party members would otherwise mean six "X gained N EXP. Points!" + -- boxes per knockout. The per-Pokemon lines are suppressed (the `false` + -- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua + -- for why that is better than a faster wall of the same text. + -- + -- The card is queued BEFORE the loop that fills it. That is not a race: + -- applyShare applies its experience immediately and only QUEUES its + -- messages, so the loop runs to completion synchronously here, while + -- the queue does not reach the card's factory until later -- by which + -- time `rows` is complete. Queueing it first is what puts the tally + -- ahead of the "grew to level" chatter it is a summary of. + local ExpPanel = V.require("ExpPanel") + local function payParty(ctx, mult) + local battle = ctx.battle + local rows = {} + battle:uiNext(function() return ExpPanel.new(battle.game, rows) end) + granting = { mult = mult } + local okAward, err = pcall(function() + for _, mon in ipairs(battle.game.save.party) do + if mon.hp > 0 then + local exp0, lv0 = mon.exp, mon.level + ctx.applyShare(mon, 1, false) + rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0, + from = lv0, to = mon.level } + end + end + end) + granting = nil + if not okAward then error(err, 0) end + 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 + return payParty(ctx, cc.mult) + end + if LetsGo.mode() == "full" and ctx and ctx.battle then + return payParty(ctx, 1) + end + return next_(ctx) + end) + + -- and the amount, per receiving mon, while that loop runs + mod.hooks:wrap("exp.gain", function(next_, c) + if not granting then return next_(c) end + return scaledGain(c, granting.mult) + end) + + -- ------- FULL owns a wild encounter from its first frame + -- + -- The engine's intro ends by sending the player's Pokemon out -- the + -- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's + -- Go wild has no player Pokemon in it at all. The send-out is exactly + -- the LAST SIX rows of the intro queue when this event fires (built in + -- BattleState's start, gated `not safari and not demo`), so they are + -- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone + -- if a future engine moves them: the veil still hides the visuals, the + -- engine just narrates a send-out that is not shown. + -- + -- Stripping them leaves showPlayerBack TRUE for the whole battle, which + -- is the flag the engine's own HUD path reads as "no player HUD" -- the + -- player's side vanishes from the readout for free. + -- + -- The veil goes up in the same breath: the capture table, installed + -- before any session exists, so the whole encounter -- wipe, "Wild X + -- appeared!", every beat between throws -- plays from the held head-on + -- seat with the player's side out of the shot. + mod.events:on("battle.started", function(payload) + 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 + -- 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 + and q[n - 1] and q[n - 1].anim == "POOF_ANIM" + and q[n - 2] and q[n - 2].text + and q[n - 3] and q[n - 3].fn + and q[n - 4] and q[n - 4].wait + and q[n - 5] and q[n - 5].fn then + for _ = 1, 6 do table.remove(q) end + end + pcall(CatchThrow.veil, b) + end) + + -- a battle ending sweeps everything: the capture epilogue, the veil, a + -- session a script tore down, and the exp context if the payout never + -- fired + mod.events:on("battle.ended", function() + expCtx = nil + pcall(CatchThrow.onBattleEnded) + end) + + CatchThrow.installInput() +end + +return LetsGo diff --git a/lib/Mat4.lua b/lib/Mat4.lua index fe0c72e..d2486d9 100644 --- a/lib/Mat4.lua +++ b/lib/Mat4.lua @@ -64,6 +64,14 @@ function Mat4.rotateX(a) 0, 0, 0, 1 } end +function Mat4.rotateZ(a) + local c, s = math.cos(a), math.sin(a) + return { c, -s, 0, 0, + s, c, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 } +end + -- The rotation a unit quaternion describes, row-major. The VR rig is what -- needs it: an OpenXR eye pose arrives as position + orientation -- quaternion, and both the eye's transform and its inverse (the view) are 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 7d167df..447d45a 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1,1527 +1,1628 @@ --- Overworld battles: fights that happen on the map you were standing on. --- --- The engine's battle is a screen: a white field with two pics on it, pushed --- over a frozen overworld that stops drawing. This turns that white field --- into the world -- the same terrain the free-roam mode extrudes, shot from --- a placed over-the-shoulder camera at a clear patch of ground nearby -- --- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move --- animation, faint slide and text box is the engine's own, drawn in the --- engine's own order. What changes is what is behind them, and where the two --- pics stand. --- --- The sequence, from the moment something picks a fight: --- --- 1. the overworld cast is culled -- every NPC vanishes, so the wipe --- plays over an empty map and no bystander is left standing in the --- arena shot --- 2. the engine's own transition wipes the screen (untouched: it is the --- right wipe, picked by the right three bits) --- 3. the battle draws over a live, window-resolution render of the arena, --- with each mon PINNED to the cell it is standing on, the camera --- drifting slowly enough to read as parallax, and a depth-of-field pass --- holding the slab of world the two of them occupy sharp --- 4. the battle ends, the cast comes back, and the player is exactly --- where they were standing --- --- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the --- player goes: nothing here writes a cell, a facing, a flag or a warp. A --- real warp would have to survive trainer sight-lines, post-battle --- dialogue, the blackout path and every script that assumes the player is --- where it left them -- and it would have to put them back afterwards. --- Moving the camera buys the whole shot and owes nothing back. --- --- The feature declines cleanly rather than half-working: no depth support, --- no open ground on the map, the row switched off, or a mesh still building --- all end at the same place, which is the battle screen the engine has --- always drawn. - --- the mod namespace (see main.lua): V.require loads a sibling module -local V = ... - -local ModSetting = V.require("ModSetting") -local BattleArena = V.require("BattleArena") -local BattleCam = V.require("BattleCam") -local BattleScene = V.require("BattleScene") -local BattleDOF = V.require("BattleDOF") -local BattleHud = V.require("BattleHud") -local BattlePics = V.require("BattlePics") -local Voxel3D = V.require("Voxel3D") -local ChunkMesher = V.require("ChunkMesher") - -local OverworldBattle = {} - --- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a --- second, which is how the glyph flip is checked from a shot run. Read --- through pcall: the loader's sandbox does not hand a mod `os`, and a --- diagnostic must never be the reason the mod fails to load. -local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end)) -if DEBUG == nil or DEBUG == false then DEBUG = nil end - -OverworldBattle.KEY = "battles" -OverworldBattle.LABEL = "3D-BTL" - --- Five rungs. Two independent choices, laid out as one ladder because they --- are one question to the player -- WHAT is standing there, and WHERE: --- --- on the MAP on two DISCS --- pics 2D-3D A 2D-3D B --- models STADIUM A STADIUM B --- --- 2D-3D A the mode this file was written for: the fight is staged on --- the map and the two Pokemon are the GB's OWN PICS, stood up --- on their tiles as quads (BattleBillboard). --- 2D-3D B those same pics on a pair of DISCS against the sky, with no --- map at all (see lib/StadiumStage.lua). The Game Boy's own --- framing with the Game Boy's own art, in three dimensions -- --- and, like every B rung, it works everywhere, including the --- caves and shop floors that have nowhere to stage a fight. --- STADIUM A the staged fight with the Pokemon Stadium battle models in --- place of those quads -- skinned, animated, and playing the --- animation the move being used actually calls for (see --- lib/Stadium.lua). The world is still the world: the fight --- happens on real ground, in the map's own weather and light. --- STADIUM B the models on the discs: both halves swapped at once. --- OFF the engine's own white battle screen. --- --- A and B is the STAGE and it is the same stage either way -- the discs do --- not know what is standing on them and BattleScene draws them off --- `arena.discs` alone, which is why the second column cost a value in this --- table and nothing else. The four combinations are all reachable rather --- than only the diagonal, because a player who cannot use the STADIUM rungs --- -- no ROM, or a ROM they would rather not go and find -- should still be --- able to have the disc framing, and because the discs are the answer to --- "this map has nowhere to fight" whichever art is standing on them. --- --- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and --- what an unrecognised stored value falls back to, and the stored value for --- this row has been `true` since the row existed. Keeping `true` at the head --- means every save written before the later rungs existed reads back as the --- 2D-3D it was written for, and a mod whose headline is "the world in 3D" --- still does not need the player to go and find the switch. --- --- Every other stored value is likewise the one it has always been -- --- "stadium" from before there was a B, "stadiumB" from before there was a --- flat one -- so no save loses the mode it chose. --- --- Both STADIUM rungs are GATED on the models existing: the mod ships no --- Pokemon Stadium data, and until the player's own ROM has been found and --- built from (StadiumInstall) the row simply has two fewer stops. See --- ModSetting.setGate for why they are skipped rather than shown and refused. --- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are --- the game's own art, so it needs nothing the base game did not ship. -OverworldBattle.FLAT_B = "flatB" - -OverworldBattle.setting = - ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, - { true, "flatB", "stadium", "stadiumB", false }, - { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" }) - :setGate(function(value) - if value ~= "stadium" and value ~= "stadiumB" then return true end - local ok, install = pcall(V.require, "StadiumInstall") - return ok and install and install.available() - end) - --- Whether the fight stands on the two carried DISCS rather than on the map --- -- the B column above, whichever row of it. Asked by stageFor (what to --- stage on), wantsFront (whether this map needs an arena at all) and, once --- the arena carries the answer as `arena.discs`, by BattleScene and --- VoxelScene for what to draw. --- --- Read straight off the row rather than through Stadium, because it is a --- question about the STAGE and half the rungs that answer yes have no --- Stadium models on them at all. -function OverworldBattle.discs() - local value = OverworldBattle.setting:get() - return (value == OverworldBattle.FLAT_B or value == "stadiumB") -end - --- Whether the VR row is ON -- read lazily, because VR requires modules --- that sit above this one. While it is, this mode stops being optional: --- the headset's battle seat, the pokedex screen and the effects plane --- all assume a fight standing on the world, and a white-field battle --- inside a headset is exactly the flat screen VR exists to replace. -local function vrOn() - local ok, vr = pcall(V.require, "VR") - return ok and vr and vr.enabled and vr.enabled() or false -end - -function OverworldBattle.enabled() - if vrOn() then return true end - return OverworldBattle.setting:get() and true or false -end - --- Whether the STADIUM rung is the one selected -- read through Stadium so --- there is one answer to that question and it lives with the mode it --- describes. Required lazily: Stadium sits above this file and requires it --- back (for the row), which a load-time require would deadlock. -function OverworldBattle.stadium() - local ok, stadium = pcall(V.require, "Stadium") - return (ok and stadium and stadium.enabled()) and true or false -end - --- ------- BACK SPRITES: the player's own mon stays on the menu --- --- The staged shot stands BOTH mons on the map, which is the mode's whole --- claim -- but it costs the one piece of framing Gen 1 is most recognisable --- by: your own Pokemon, seen from behind, sitting on top of the battle menu --- with its feet on the box. That silhouette is the series' shot. --- --- So BACK SPRITES is offered as a middle setting rather than a compromise --- imposed on everyone. With it on the foe is still geometry standing on its --- tile at the far end of the arena, and the player's side goes back to being --- the GB's own flat back pic in the GB's own slot: same art, same 2x, same --- feet on row 96. --- Nothing else about the shot moves -- the arena, the camera and the drift are --- solved exactly as they were, so the foe stands where it always stood and the --- player's cell is simply empty ground in the foreground. --- --- OFF by default: what the mode advertises is the pair of them out there. -OverworldBattle.BACK_KEY = "battleBack" -OverworldBattle.BACK_LABEL = "BACK SPRITES" - -OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, - OverworldBattle.BACK_LABEL, - { false, true }, { "OFF", "ON" }) - --- Gated on 3D-BTL rather than read alone: with staged battles off there is no --- staged shot for a back pic to be pinned in FRONT of, and the engine's own --- battle screen already draws exactly this. And held OFF under VR: the --- headset stands both mons on the world -- a flat back pic pinned to the --- 2D frame would keep your own mon off the arena the battle seat looks at. -function OverworldBattle.backPinned() - if not OverworldBattle.enabled() then return false end - if vrOn() then return false end - return OverworldBattle.backSetting:get() and true or false -end - --- Whether a pic is the one drawn in the GB's own slot with its feet on the --- text box, rather than geometry standing out on the map. --- --- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back --- that holds the slot until "Go!" -- because that is the only pic this mod --- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its --- tile whichever mode is on, and with the mode off the player's side is one --- too, so both of those keep the open bottom that lets the arena through a --- stride. What the answer buys is in BattlePics: a pic on the box has nothing --- behind its lowest row, so its bottom edge seals. --- Read by TRUTHINESS rather than against nil, because sideTexture blanks the --- side it is not rendering by setting the field to FALSE (see OFF) and holds --- it that way for the whole render -- during which the pic layer runs, and --- picImage asks this. A nil test passes a `false` straight through to the --- index below, and the error comes out of sideTexture into the pcall that --- calls it: the foe's billboard is dropped for the frame and the Pokemon --- simply is not there. -function OverworldBattle.pinnedPic(battle, img) - if not (battle and img) then return false end - if not OverworldBattle.backPinned() then return false end - if img == battle.playerBackPic then return true end - local player = battle.player - return (player and img == player.sprite) and true or false -end - --- ------- both mons face you --- --- Standing on a map, seen from in front, a Pokemon showing you its BACK is --- wrong twice over: it is turned away from the camera that is looking at it, --- and the back pics are a different, smaller drawing made for a slot the --- player never really sees. So the player's side asks for the FRONT pic too, --- through the engine's own pokemon.sprite hook -- the seam that exists for --- exactly this, so no battle code has to be touched to get it. --- --- Unless BACK SPRITES is on, the setting that asks for the back pic back: --- that mon is drawn in its own slot on the menu, seen from behind, and the --- front art would be it turned round to face the player it belongs to. --- --- Answered BEFORE a battle exists, because the battler is built before the --- battle is pushed. So it cannot ask whether this fight is staged; it asks --- whether one on this map WOULD be -- the row is on, the 3D pass is --- available, and the map has an arena -- which is the same question with the --- same answer a moment later. Cached per map, because the arena search walks --- the whole grid and this runs once per battler. -local staged = { mapId = nil, ok = false } - -function OverworldBattle.wantsFront() - if not OverworldBattle.enabled() then return false end - if OverworldBattle.backPinned() then return false end - if not Voxel3D.available() then return false end - -- required here rather than through the file's own helper: this runs - -- while a battler is being built, which is before that helper is defined - local g = require("src.core.Game") - local ow = g and g.overworld - if not (ow and ow.map and ow.player) then return false end - -- a B rung carries its own stage, so the answer is yes on every map and - -- there is nothing to search or to cache - if OverworldBattle.discs() then return true end - if staged.mapId ~= ow.map.id then - local ok, arena = pcall(BattleArena.find, ow.map, - ow.player.cellX, ow.player.cellY, - ow.player.surfing) - staged = { mapId = ow.map.id, ok = (ok and arena) and true or false } - end - return staged.ok -end - --- ------- where the engine's own pics stand --- --- The GB draws the player's back pic with its feet on the text box at row 96 --- and its 7x7-tile slot centred on x=40, and the enemy's front pic --- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two --- points are the pics' FEET, they hold for every species at every scale (the --- engine's placement helpers pin the bottom edge and the centre), and they --- are what BattleCam is solved to put the two arena cells under. --- --- Which makes the pin a subtraction: whatever the drift has done to the --- camera this frame, each pic moves by its own cell's projected position --- minus its anchor. At the middle of the drift that is zero. -OverworldBattle.ANCHOR = { - player = { 26, 96 }, - enemy = { 124, 56 }, -} - --- ------- how big a mon is --- --- Not a decision made here. A pic is drawn at its own integer scale -- 1x for --- a 56px front pic, 2x for a 32px back one -- because that is the only way it --- keeps every pixel the artist drew, and the CAMERA is solved so that one --- overworld square is that big on screen (see BattleCam). The mon fits its --- tile because the tile was sized to the mon, not the other way round. -OverworldBattle.SLOT_W = { front = 56, back = 32 } - --- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and --- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at --- the anchors above. -OverworldBattle.HUD_RECT = { - enemy = { 8, 0, 80, 32 }, - player = { 72, 56, 88, 40 }, -} - --- ------- the box at the bottom, on the same glass --- --- The HUDs got frosted panels because black glyphs on grass are not readable. --- The battle's text box and its menu had the opposite problem and the same --- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was --- the field's own colour when the field was white and is a sheet of paper laid --- over the bottom third of the diorama now that it is not. --- --- So the box gets exactly what the HUDs get: the world behind it, blurred to --- frosted glass and laid back down translucent, with the border and the text --- drawn over it unchanged, and the same brightness verdict flipping the ink --- when the ground under it is dark. Only the FILL is taken away -- every glyph --- the engine draws inside the box is still the engine's own, in its own place. --- --- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects. --- READ-ONLY duplicates of that function's own branches, the same kind of --- mirror hudLive is and for the same reason: there is no seam that reports "a --- move menu is up", and glass has to go down BEFORE the box that sits on it. --- The worst a future engine change can do is frost a rectangle nothing lands --- on, or leave a box unfrosted -- never break a battle. --- --- Each rect stops where the next one starts rather than overlapping it: two --- panels over the same pixels would frost it twice and leave a visible step --- along the seam. -OverworldBattle.TEXT_RECT = { - box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always - -- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows - -- above the box above -- its last tile row sits inside that one - moves = { 0, 64, 88, 32 }, - -- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way - mimic = { 0, 56, 128, 40 }, -} - --- How far apart the two anchors are: the spacing every move animation was --- authored against, and so the yardstick the live pair is measured with. -OverworldBattle.ANCHOR_SPAN = math.sqrt( - (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2 - + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2) - --- The effects layer's scale for this shot: how far apart the two mons --- actually are on screen, over how far apart the slots they were authored --- for were. Clamped hard at both ends -- an effect is pixel art and a wild --- factor is worse than a slightly wrong one -- and held at exactly 1 when --- the marks coincide, which is a projection about to degenerate rather --- than a pair that has genuinely closed up. -OverworldBattle.ANIM_SCALE_MIN = 0.5 -OverworldBattle.ANIM_SCALE_MAX = 2.0 - -function OverworldBattle.animScale(shot, px, py) - if not (shot and shot.enemy and px and py) then return 1 end - local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py - local span = math.sqrt(dx * dx + dy * dy) - if not (span > 1) then return 1 end - local k = span / OverworldBattle.ANCHOR_SPAN - return math.max(OverworldBattle.ANIM_SCALE_MIN, - math.min(OverworldBattle.ANIM_SCALE_MAX, k)) -end - -function OverworldBattle.textRects(battle) - if not battle or battle.blankForAskName then return {} end - local r = OverworldBattle.TEXT_RECT - local out = { box = r.box } - if battle.phase == "moveSelect" then - out.moves = r.moves - elseif battle.phase == "mimicSelect" then - out.mimic = r.mimic - end - return out -end - --- ------- the HUDs, out at the window's own edges --- --- The battle screen is 160x144 in the MIDDLE of the window and the world is the --- whole of it. That left both HUD blocks huddled together in the middle of the --- frame with map showing on either side of them, which reads as a Game Boy --- screenshot pasted over a diorama rather than as the diorama's own furniture. --- --- So each block is snapped to its own side: the foe's to the left edge of the --- window, the player's to the right. Nothing about either block changes -- same --- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar --- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape --- of the GB screen there is nowhere to go and the snap is a no-op. --- --- They cannot simply be MOVED there: the engine draws them into the 160x144 UI --- canvas and everything outside it is clipped away. So the layer is rendered to --- a texture and composited into the WORLD image instead, which is the one --- surface in this mode that covers the whole window. - --- The rows each block is cut out of, full width. Generous on purpose: --- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches --- further than the panel does, and the pokeball rows and the safari ball count --- belong to the block whose rows they sit in. Nothing drawHUDs draws lies --- outside rows 0-96, and the two bands split that between them. -OverworldBattle.HUD_BAND = { - enemy = { 0, 0, 160, 48 }, - player = { 0, 48, 160, 48 }, -} - --- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted --- glass is cut to, plus the x its band is blitted at. --- --- The foe's panel starts at the window's left edge and the player's ends at the --- right one. The vertical is untouched, so both stay on the rows the GB put --- them on. A band's own origin sits outside the window by the panel's inset -- --- the couple of pixels a HUD shake can push past the edge are clipped there, --- which is the whole cost of the snap and is invisible. -function OverworldBattle.snapRects(shot) - local s = shot.scale - local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player - local ex = -e[1] * s -- foe: panel's left edge to 0 - local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side - local rects = { - enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, - player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s }, - } - return rects, { enemy = ex, player = px } -end - --- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox --- blit will actually put it. The text box has not moved anywhere -- it is drawn --- where it always was -- but its glass is laid into the world image alongside --- the HUDs' (see snapHUDs), which is the surface that reaches the screen a --- pixel to a pixel rather than magnified out of a 160x144 canvas. -local function toWorld(rect, shot) - local s = shot.scale - return { shot.lx + rect[1] * s, shot.ly + rect[2] * s, - rect[3] * s, rect[4] * s } -end - --- ------- the live battle --- --- nil when no overworld battle is running. Never more than one: battles do --- not nest. -local session = nil - -local function isIOS() - return love.system and love.system.getOS and love.system.getOS() == "iOS" -end - -local function game() - return require("src.core.Game") -end - --- Whether this frame's HUDs went out to the window's edges instead of being --- drawn in the GB frame. False whenever the composite could not be made, which --- is what leaves the in-frame HUD as the fallback rather than no HUD at all. -local function snapped() - return (session and session.snapped) and true or false -end - --- Put the map's cast back. Both lists are handed back by identity, so --- anything that captured one before the battle still sees the same table. -local function restoreCast() - if not (session and session.state) then return end - if session.entities then session.state.entities = session.entities end - if session.ghosts then session.state.ghosts = session.ghosts end - session.entities, session.ghosts = nil, nil -end - --- Cull them. The player stays -- they are not an NPC, they are who the --- battle belongs to, and Fly/surf animations and the save's own capture read --- state.player through this list. --- --- Only the DRAW lists are touched, and only while the overworld is frozen --- underneath a battle: StateStack updates the top state alone, so nothing --- walks, wanders, triggers or collides against a list that is short for --- these frames. The originals go back at battle.ended. -local function cullCast(state) - session.entities = state.entities - session.ghosts = state.ghosts - state.entities = { state.player } - state.ghosts = {} -end - --- ------- one right battle layout --- --- Everything this file composes is measured in the GB's own 160x144 frame: the --- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs --- the frosted panels are cut to, and the full-frame white intercepted to let --- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a --- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those --- -- the mons would stand where no camera was solved for them, and the panels --- would land beside the HUDs they are supposed to be under. --- --- So while a fight can be staged on the map there is one right answer, and it --- is SET rather than worked around. The engine reads the option live --- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the --- top state for its surface the same way), so writing it here lands on the --- battle being pushed as well as every one after it. --- --- This is the last line rather than the first: the OPTIONS menu takes the row --- off the list and pins the value while 3D-BTL is on (see main.lua), so a --- player is never offered a switch that gets reverted under them. What reaches --- here is a value that arrived some other way -- a save written before the mod --- was installed, the mod manager's own page, another mod. -function OverworldBattle.forceOG(g) - g = g or game() - local opts = g and g.save and g.save.options - if not opts or opts.battleLayout ~= "wide" then return false end - opts.battleLayout = "og" - if g.writeOptions then pcall(g.writeOptions, g) end - return true -end - --- Where THIS fight stands, on whichever rung is running: the map's own --- ground, or the pair of discs a B rung carries with it. --- --- The one place the two columns actually diverge, and it is worth stating --- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a --- map whose authored entry is a refusal -- and the battle then plays exactly --- as the vanilla game does. A B rung cannot fail: its stage is not something --- the map has to have room for, so a fight in the tightest cave in Kanto is --- staged as readily as one on Route 1. -function OverworldBattle.stageFor(state) - if OverworldBattle.discs() and Voxel3D.available() then - local okStage, arena = pcall(function() - return V.require("StadiumStage").arena(state.map) - end) - if okStage and arena then return arena end - -- the discs could not be built; fall through to the map, which is a - -- worse picture but a real one - end - local okFind, arena = pcall(BattleArena.find, state.map, - state.player.cellX, state.player.cellY, - state.player.surfing) - return (okFind and arena) or nil -end - --- Stage a battle triggered from `state`, if this mode can. Returns true when --- a session started -- which is also the only case where anything visible --- changes, so a map with no room for an arena plays exactly the vanilla --- battle it always did, cast and all. -function OverworldBattle.begin(state, battle) - OverworldBattle.finish() - if not OverworldBattle.enabled() then return false end - if not (state and state.map and state.player) then return false end - if not Voxel3D.available() then return false end - - local arena = OverworldBattle.stageFor(state) - if not arena then return false end - - -- the fight is staged from here on, so the layout it is composed for is not - -- optional any more (see forceOG) - OverworldBattle.forceOG() - - session = { state = state, arena = arena, battle = battle, shot = nil, - armed = false, token = 0 } - cullCast(state) - BattleCam.reset() - -- and, on the STADIUM rung, the pair of models that will stand on this - -- arena's two cells. Declines quietly on any other rung. - pcall(function() V.require("Stadium").begin(arena) end) - return true -end - --- The fallback entry point: a battle that arrived without going through the --- overworld's own pushBattle (a link battle, a script pushing a BattleState --- directly). Nothing visible depends on the cull for those -- the wipe has --- already been and gone -- but the arena still has to be picked. -function OverworldBattle.ensure(battle) - if session then - -- a battle pushed through the overworld reaches begin() before it is - -- built far enough to draw; battle.started is where it is finished - if battle and not session.battle then session.battle = battle end - return - end - local g = game() - local ow = g and g.overworld - if ow and ow.map then OverworldBattle.begin(ow, battle) end -end - --- The arena this battle is staged on, or nil. Read by the shot driver so a --- screenshot can be labelled with the ground it was taken on. -function OverworldBattle.arena() - return session and session.arena or nil -end - -function OverworldBattle.finish() - if not session then return end - restoreCast() - session = nil - Voxel3D.camera = nil - pcall(function() V.require("Stadium").finish() end) -end - --- ------- per-frame --- --- Driven from the voxel pipeline's update hook, which the engine ticks every --- frame regardless of which state is on top -- including the frames the --- transition wipe covers, which is what gets the arena's meshes built before --- the first battle frame needs them. --- --- The scene is rendered HERE rather than inside the battle's draw, because --- update runs with no canvas bound: a 3D pass that binds a depth target and --- unbinds to the screen when it is done cannot do that in the middle of --- someone else's frame without putting the frame back itself. -function OverworldBattle.update(dt) - if not session then return end - - local g = game() - local top = g and g.stack and g.stack:top() - local ow = g and g.overworld - -- A battle that ended without saying so (a script tearing the state down, - -- a path that never emits battle.ended) would otherwise leave the cast - -- culled for good. Armed only once something has actually covered the - -- overworld, because begin() runs while the overworld is still on top. - if top ~= nil and top ~= ow then - session.armed = true - elseif session.armed then - OverworldBattle.finish() - return - end - - -- Whether the shot is the player's to steer at all. BACK SPRITES pins - -- their own mon to the GB's slot on the menu while the foe stands out on - -- the map, and there is no angle that half-framed, half-solid - -- composition survives -- so under it the camera holds the shot the rig - -- was solved for (the slow drift aside, which was always there). Polled - -- per frame rather than latched at battle start: the row is reachable - -- from the mod manager's page mid-session. - BattleCam.steerable = not OverworldBattle.backPinned() - -- the right stick, read as a rate before the rig is built from it: the - -- wheel, the keys, the mouse and a drag all arrive as events and have - -- already landed, but a stick is a HELD position and only a tick can - -- turn it into travel (CamControl, which owns every one of those inputs) - pcall(V.require("CamControl").tick, dt) - BattleCam.update(dt) - -- the battle only exists once it has been pushed; a session opened at - -- pushBattle time has it, one opened from battle.started was handed it - session.battle = session.battle or (top ~= ow and top or nil) - -- the world pass is hidden behind the battle, so mesh builds get the wide - -- slice: nothing visible can hitch on them - ChunkMesher.pump(true) - - -- The STADIUM models, ahead of the pics, because what they decide is - -- WHICH pics are needed: a side a model is standing on gets no billboard - -- texture rendered for it at all (see Stadium.covers). Posed and skinned - -- here too, once for the frame -- the sun pass, the camera and, in a - -- headset, both eyes all draw the same skinned meshes. - pcall(function() - local host = (session.arena and session.arena.map) or session.state.map - V.require("Stadium").update(dt, session.battle, - BattleScene.groundY(host, session.arena)) - end) - - -- The mons' textures are rendered HERE, with no canvas bound, for the same - -- reason the scene is: the pics layer binds its own targets, and doing that - -- inside somebody else's frame means putting the frame back afterwards. - local okTex, textures = pcall(OverworldBattle.textures, session.battle) - if not okTex then textures = nil end - -- stashed for the VR eye pass, which stands these same pics on the map - -- in ITS view of the world (VoxelScene's eyes path). Stashed HERE - -- because rendering them binds canvases, which the eye pass -- mid-scene - -- when it wants them -- must never do; reading a stashed canvas is free. - session.textures = textures - -- and the move-animation layer, for the same eyes -- rendered only - -- while a headset is actually watching, because only the VR world - -- pass draws it (the flat screen has the animations in-frame already) - session.animTex = nil - local okVR, vrOn = pcall(function() - local vr = V.require("VR") - return vr.active and vr.active() or false - end) - if okVR and vrOn and session.battle then - local okA, anim = pcall(OverworldBattle.animTexture, session.battle) - if okA then session.animTex = anim end - end - session.token = (session.token or 0) + 1 - local ok, shot = pcall(BattleScene.render, session.state, session.arena, - textures, session.token) - if not ok then - -- One failure retires the arena for THIS battle and nothing else: the - -- battle screen carries on as the engine's own, the free-roam pipeline - -- this runs inside keeps rendering the overworld, and the next battle - -- tries again. Rethrowing would hand the whole voxel mode to Pipelines' - -- guard, which retires a pipeline for the session. - session.shot = nil - session.snapped = false - session.broken = true - V.mod.log:warn("overworld battle scene failed: %s -- this battle draws " - .. "on the plain battle background", tostring(shot)) - return - end - session.snapped = false - if shot and shot.canvas then - -- the depth of field is measured off the two marks: the slab in focus is - -- the one the mons are standing in, at whatever the drift has done to - -- where that lands - local y1 = shot.ly + shot.player[2] * shot.scale - local y2 = shot.ly + shot.enemy[2] * shot.scale - local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph) - local okDof, blurred = pcall(BattleDOF.apply, shot.canvas, - focusY, band, range) - if okDof and blurred then shot.canvas = blurred end - -- the frosted glass the HUDs sit on is built from the FINISHED backdrop, - -- so a panel over a blurred far field is frosted from what is actually - -- behind it - pcall(BattleHud.build, shot.canvas) - -- and then the HUDs go ON that backdrop, snapped out to the window's own - -- edges (snapHUDs). Here rather than in the battle's draw for the same - -- reason the scene is: it binds a canvas of its own. After the frost, so - -- the glass is frosted from the world alone and never from the glyphs - -- about to sit on it. - local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) - session.snapped = (okHud and up) and true or false - -- once per battle, not once per frame: a driver that cannot do this cannot - -- do it sixty times a second either, and the fallback is silent and fine - if not okHud and not session.hudWarned then - session.hudWarned = true - V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " - .. "in the battle frame this battle", tostring(up)) - end - end - session.shot = shot -end - --- The finished shot for this frame, or nil when there is none and the battle --- should draw the way it always did. -function OverworldBattle.shot() - if not session or session.broken then return nil end - local s = session.shot - if s and s.canvas then return s end - return nil -end - --- The staged fight's WORLD-side pieces, for a pass that stands the mons in --- its own view of the map rather than in the arena's composed shot -- the --- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed --- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets --- per-eye cards), the live textures table (for the hit-flash flag), and the --- token the shadow signature keys on. nil while nothing is staged, the --- arena is broken, or the pics have not been rendered yet. -function OverworldBattle.worldCards() - if not (session and session.arena and not session.broken) then return nil end - local tex = session.textures - if not tex then return nil end - local host = (session.state and session.state.map) or nil - if not host then return nil end - local groundY = BattleScene.groundY(host, session.arena) - return BattleScene.monCards(session.arena, groundY, tex), tex, session.token -end - --- The live session's BATTLE STATE, once the pushed battle has been met --- (session.battle fills in from the stack in update). The VR quad reads --- it to tell "the battle screen is on top" from "a menu is over the --- battle" -- the UI-only panel is right for the first and wrong for the --- second. nil with no session, a broken one, or a battle not yet pushed. -function OverworldBattle.battle() - if not (session and not session.broken) then return nil end - return session.battle -end - --- The move-animation layer as a texture: the engine's own drawAnimLayer, --- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized --- transparent canvas of its own. This is what stands the effects up in --- the VR eyes' world -- see worldAnim below -- the same move the pics --- made through sideTexture: let the engine draw what it always draws, --- catch it on a canvas, stand the canvas in the scene. -local animLayer = nil --- the engine's own drawAnimLayer, captured by install(). Declared HERE, --- above the function that reads it: a local declared further down the --- chunk would leave this function reading a global of the same name -- --- nil forever, and the effects silently absent from the eyes (the bug --- this comment is the tombstone of). -local innerAnim = nil - -function OverworldBattle.animTexture(battle) - if not (innerAnim and battle) then return nil end - if not (love.graphics and love.graphics.newCanvas) then return nil end - if not animLayer then - local ok, c = pcall(love.graphics.newCanvas, - BattleScene.GB_W, BattleScene.GB_H) - if not (ok and c) then return nil end - pcall(c.setFilter, c, "nearest", "nearest") - animLayer = c - end - local g = love.graphics - local prevCanvas = g.getCanvas() - local ok = pcall(function() - g.push("all") - g.origin() - g.setCanvas(animLayer) - g.clear(0, 0, 0, 0) - g.setBlendMode("alpha") - g.setColor(1, 1, 1, 1) - innerAnim(battle, false) - g.pop() - end) - if not ok then pcall(g.pop, g) end - if prevCanvas then pcall(g.setCanvas, g, prevCanvas) - else pcall(g.setCanvas, g) end - return ok and animLayer or nil -end - --- The staged fight's effects, for the VR eyes: the animation layer plus --- the plane to stand it on (BattleScene.fxCard -- anchored so a hit --- authored at a slot lands on the mon standing in for that slot). nil --- while nothing is staged or no layer was rendered this frame. -function OverworldBattle.worldAnim() - if not (session and session.arena and not session.broken) then return nil end - local tex = session.animTex - if not tex then return nil end - local host = (session.state and session.state.map) or nil - if not host then return nil end - local groundY = BattleScene.groundY(host, session.arena) - local model = BattleScene.fxCard(session.arena, groundY, - OverworldBattle.ANCHOR) - if not model then return nil end - return tex, model -end - --- Where the staged fight STANDS -- the arena and its floor height -- for a --- camera that wants to look at it rather than draw it (the VR battle --- mount). Answered as soon as the stage exists, textures or not: the --- camera should be seated behind the fade before the first pic lands. --- nil whenever no fight is staged on the world. -function OverworldBattle.stage() - if not (session and session.arena and not session.broken) then return nil end - local host = (session.state and session.state.map) or nil - if not host then return nil end - return session.arena, BattleScene.groundY(host, session.arena) -end - -function OverworldBattle.invalidate() - BattleDOF.invalidate() - BattleHud.invalidate() - BattlePics.invalidate() - -- the STADIUM models hold meshes and textures of this graphics context - -- like everything else here does - pcall(function() V.require("Stadium").invalidate() end) -end - --- ------- the battle screen's background --- --- BattleState opens by filling 160x144 white -- that fill IS the battle's --- background, and in the colorized pipeline it is also the BG canvas's clear --- (nothing else clears it, so skipping it outright would ghost last frame). --- So for the length of one draw, that one call is intercepted: on the two --- offscreen canvases it becomes a transparent clear, so the shade-remap pass --- composites the HUD and the text box over the arena and leaves the empty --- field showing it; on the screen it is simply dropped, because the UI canvas --- has already been cleared transparent for the world to show through. --- --- Matched exactly -- fill, the full frame, at the origin, in opaque white -- --- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the --- move-animation flash (which is white at 0.85) all pass through untouched. --- --- This is a shim over love.graphics and it is the one invasive thing here, --- so it is scoped as tightly as it can be: installed around a single call, --- removed on the way out including on error, and never live outside a battle --- frame this mode is drawing. -local function withoutBackgroundFill(battle, fn) - local g = love.graphics - local rectangle = g.rectangle - g.rectangle = function(mode, x, y, w, h, ...) - if mode == "fill" and x == 0 and y == 0 - and w == BattleScene.GB_W and h == BattleScene.GB_H then - local r, gr, b, a = g.getColor() - if r > 0.99 and gr > 0.99 and b > 0.99 then - -- Two different full-frame whites, both replaced rather than drawn. - -- - -- OPAQUE is the battle's background, and on the offscreen canvases it - -- doubles as their clear, so there it becomes a transparent one. - -- - -- TRANSLUCENT is the hit flash. Over a white field that reads as a - -- flash; over a world it whites out the map, the HUD and the text box - -- together. BattleScene puts it back on the mons alone. - if a > 0.99 then - local target = g.getCanvas() - if target ~= nil - and (target == battle.bgCanvas or target == battle.waveCanvas) then - g.clear(0, 0, 0, 0) - end - end - return - end - end - return rectangle(mode, x, y, w, h, ...) - end - local ok, err = pcall(fn, battle) - g.rectangle = rectangle - if not ok then error(err, 0) end -end - --- ------- the box, without its paper --- --- Font.drawBox is a white fill and then six border glyphs, and the fill is the --- opaque slab the frosted panel underneath is there to replace. So for the --- length of one drawTextArea the white fills are dropped and everything else --- -- the border, the text, the cursor, the down arrow -- draws exactly as it --- always did, over the glass instead of over paper. --- --- Every fill drawTextArea issues is one of those: the box's own, and the two --- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes --- the border glyphs that hardware would have overwritten. Both are opaque --- white, both are paper, and both go. --- --- The same shim shape as withoutBackgroundFill above, and scoped as tightly: --- installed around a single call, removed on the way out including on error, --- never live outside a battle frame this mode is drawing. -local function withoutBoxFill(battle, fn) - local g = love.graphics - local rectangle = g.rectangle - g.rectangle = function(mode, ...) - if mode == "fill" then - local r, gr, b, a = g.getColor() - if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end - end - return rectangle(mode, ...) - end - local ok, err = pcall(fn, battle) - g.rectangle = rectangle - if not ok then error(err, 0) end -end - --- ------- the hour's light, on a pic that is not geometry --- --- Everything standing in the arena goes through the voxel shader, and that --- shader multiplies by the hour's tint: at dusk the whole diorama warms, at --- night it goes blue, and the two mons' cards go with it because they are --- drawn in the same pass as the ground they stand on. --- --- A back pic pinned to the menu is not in that pass. It is the engine's own --- flat blit over the finished shot, so it arrived at noon while the world --- behind it was at midnight -- a mon lit by nothing in the frame. --- --- So the tint is applied by hand, to that one draw. Every colour the pics --- layer sets is multiplied on its way past, which is the whole of it: the --- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw --- colour, so tinting the colour tints the pixels -- and the alpha, the faint --- slide's fade and the blink's own colour all compose with it rather than --- being overwritten. --- --- What this does NOT get is the sun: the cards are shadow-mapped, so one --- standing under a tree is darker than the tint alone, and this pic has no --- position in the scene to be shadowed at. It carries the hour and not the --- weather, which is the part the eye reads. -local function 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 - -- the layer leaves whatever colour it last set, and that one is tinted; - -- hand the next caller plain white rather than a dimmed one - setColor(1, 1, 1, 1) - if not ok then error(err, 0) end -end - --- ------- the mons, as textures for the 3D pass --- --- The two Pokemon are not composited over the world any more: they are quads --- standing in it (see BattleBillboard). What that needs from the battle --- screen is a TEXTURE per side -- and the honest way to get one is to let the --- engine draw its own pics layer, unchanged, into a canvas. --- --- So the layer is rendered twice, once per side, with the other side --- falsified out of existence by nulling exactly the fields its branches --- test. Everything the engine does to a pic comes along for free that way: --- the trainer pic before the send-out, the grow-out-of-the-ball scale, the --- faint slide, the damage blink, the squish, every SE displacement. None of --- it is reimplemented and none of it can drift. --- --- Two things are forced during that render. The scale, to 1, so the texture --- carries the artwork's own pixels and the BILLBOARD does the sizing; and the --- placement, so the pic lands centred on a known column with its feet on a --- known row. That known point is what the quad is then hung from. -local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline -local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot - -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 - -local texCanvas = {} -local innerPics = nil -- captured by install() -local innerHUDs = nil -- likewise, for the snapped HUD layer --- (innerAnim, their sibling, is declared up beside animTexture, which --- sits earlier in the chunk than this group and must see the local) - -local function texCanvasFor(side) - local c = texCanvas[side] - if c then return c end - local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W, - BattleScene.GB_H, { dpiscale = 1 }) - if not ok then return nil end - made:setFilter("nearest", "nearest") - texCanvas[side] = made - return made -end - --- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own --- guards, so an empty canvas is never hung on a quad: a fainted, hidden or --- not-yet-sent-out mon simply has no billboard this frame. -local function sideVisible(battle, side) - if side == "enemy" then - if battle.showEnemyTrainer and battle.trainerPic then return true end - return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden - and not battle.enemySendingOut - and not battle:fxHidden(battle.enemy)) and true or false - end - if battle.showPlayerBack and battle.playerBackPic then return true end - local hide = battle.safari or battle.demo - return (battle.player and battle.player.sprite and not hide - and not battle.sendingOut - and not battle:fxHidden(battle.player)) and true or false -end - -local OFF = { - enemy = { player = false, showPlayerBack = false }, - player = { enemy = false, showEnemyTrainer = false }, -} - --- Render one side's pics layer into its canvas and report where the pic's --- feet ended up, in canvas coordinates. -function OverworldBattle.sideTexture(battle, side) - if not (innerPics and battle) then return nil end - -- On the STADIUM rung a side standing a MODEL needs no pic: rendering one - -- anyway would hang a second, flat copy of the same Pokemon on the same - -- cell. Asked per side, so a species with no pack -- or a substitute - -- doll, or the trainer before the send-out -- still comes through here. - local okS, covered = pcall(function() - return V.require("Stadium").covers(battle, side) - end) - if okS and covered then return nil end - if not sideVisible(battle, side) then return nil end - local canvas = texCanvasFor(side) - if not canvas then return nil end - - local g = love.graphics - local prevCanvas = g.getCanvas() - local prevBlend, prevAlpha = g.getBlendMode() - -- The pic-window scissors are in the battle screen's fixed coordinates and - -- would clip a pic that has been moved to the middle of its own canvas. - -- There is nothing here for them to protect -- no HUD, no text box, just - -- the one pic -- so they are switched off for the render. - local setScissor, intersectScissor = g.setScissor, g.intersectScissor - local getScissor = g.getScissor - g.setScissor = function() end - g.intersectScissor = function() end - g.getScissor = function() return nil end - - local saved = {} - for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end - texturing = side - - 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) - end) - - texturing = nil - for k in pairs(OFF[side]) do battle[k] = saved[k] end - g.setScissor, g.intersectScissor, g.getScissor = - setScissor, intersectScissor, getScissor - if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end - g.setBlendMode(prevBlend or "alpha", prevAlpha) - if not ok then error(err, 0) end - - local ax, ay = TEX_AX, TEX_AY - local trainer = false - -- The intro trainer pic draws itself straight into its own 7x7 slot rather - -- than through the placement helpers, so it is hung from that slot instead. - if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then - ax, ay, trainer = TRAINER_AX, TRAINER_AY, true - elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then - trainer = true - end - return { canvas = canvas, ax = ax, ay = ay, trainer = trainer } -end - --- Whether the hit flash is showing this frame. --- --- Mirrors BattleState:draw's own test, because the flash is a DRAW-time --- decision there (a counter plus the frame parity that makes it flicker) and --- there is no seam that reports it. Read-only, so the worst a future engine --- change can do is flash on a frame the engine would not have. -function OverworldBattle.flashing(battle) - local fx = battle and battle.fx - if not (fx and fx.flash and fx.flash > 0) then return false end - return (battle.frame or 0) % 4 < 2 -end - --- Both sides, or nil when neither has anything to show. --- --- One side under BACK SPRITES: the player's mon is not standing on the map at all --- there, it is on the menu, so it has no card to be a texture for -- and --- nothing downstream has to know that. No billboard, and no shadow on the --- ground under a mon that is not on it. -function OverworldBattle.textures(battle) - if not battle then return nil end - local out = {} - local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy") - local okP, player = true, nil - if not OverworldBattle.backPinned() then - okP, player = pcall(OverworldBattle.sideTexture, battle, "player") - end - out.enemy = okE and enemy or nil - out.player = okP and player or nil - -- On the STADIUM rung both sides can legitimately have no pic -- the pair - -- of them are models -- and this table must still come back, because it - -- carries the HIT FLASH, and because the VR eye pass uses its presence to - -- decide there is a staged fight to draw at all. - local okStanding, standing = pcall(function() - return V.require("Stadium").standing() - end) - if not (out.enemy or out.player or (okStanding and standing)) then - return nil - end - out.flash = OverworldBattle.flashing(battle) - return out -end - --- ------- engine seams --- --- Four wraps, each idempotent so a hot reload cannot stack them. - -function OverworldBattle.install() - local OverworldState = require("src.world.OverworldController") - if not OverworldState.dramaticShapeBattleHook then - local inner = OverworldState.pushBattle - -- The one place the overworld starts a battle, and it runs BEFORE the - -- transition is pushed -- which is what lets the cull happen off-screen - -- and the wipe play over a map with nobody on it. - function OverworldState:pushBattle(battle) - pcall(OverworldBattle.begin, self, battle) - return inner(self, battle) - end - OverworldState.dramaticShapeBattleHook = true - end - - -- the STADIUM rung's own four wraps, which drive the models' animations - -- off the fight (see Stadium.install). Idempotent in the same way, and - -- installed whichever rung the row is on: the wraps do nothing at all - -- while no stadium session is live. - pcall(function() V.require("Stadium").install() end) - - local BattleState = require("src.battle.BattleState") - if BattleState.dramaticShapeBattleHook then return end - - -- Integer scales only. The camera is solved to make one overworld square - -- exactly big enough for a pic at its own integer scale (see BattleCam), so - -- the fit never has to come out of the pixels -- and a species override or - -- a battle_sprite_scales entry that asks for 1.7x would undo that and - -- resample the sprite into mush. Rounded rather than refused, so such a mod - -- still gets the bigger or smaller mon it asked for, on the pixel grid. - local innerScale = BattleState.resolveBattleScale - function BattleState.resolveBattleScale(data, side, path, species) - local base = innerScale(data, side, path, species) - -- 1:1 into the billboard texture: the artwork's own pixels, with the - -- quad's world size doing every bit of the scaling. Anything else would - -- resample the sprite twice -- once into the texture and again on the way - -- to the screen -- and a twice-resampled Gen 1 pic is mush. - if texturing then return 1 end - if not OverworldBattle.shot() then return base end - return math.max(1, math.floor((tonumber(base) or 1) + 0.5)) - end - - -- Keyed-out whites inside a pic used to be filled by the white field - -- behind it. There is a world back there now, so they are filled here - -- instead -- see BattlePics, which puts the paper back without touching - -- the silhouette. - -- - -- The pinned pic is told that its feet are on the box, which is what lets - -- the pale-bodied back sprites be filled at all: their bellies leak out - -- through an opening too wide to read as a drain, and only the box under - -- them settles that it is not a hole. Passed the pre-bake image, because - -- that is the one the battle holds a reference to. - local innerPic = BattleState.picImage - function BattleState:picImage(img) - local out = innerPic(self, img) - if not OverworldBattle.shot() then return out end - return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) - end - - -- While a billboard texture is being rendered both pics are put in the same - -- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad - -- has one anchor to hang from whichever side and whichever species it is - -- carrying. Outside that render both helpers answer exactly as they always - -- did. - local innerBack = BattleState.backPlacement - function BattleState.backPlacement(w, h, pad, padL, scale) - local x, y, s = innerBack(w, h, pad, padL, scale) - if not texturing then return x, y, s end - return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s - end - - local innerFront = BattleState.frontPlacement - function BattleState.frontPlacement(ex, ey, w, h, scale) - local x, y, s = innerFront(ex, ey, w, h, scale) - if not texturing then return x, y, s end - return TEX_AX - w * scale / 2, TEX_AY - h * scale, s - end - - local innerDraw = BattleState.draw - function BattleState:draw() - local shot = OverworldBattle.shot() - -- AskName blanks the field on purpose (the nickname prompt is meant to - -- sit on nothing); leave that one alone. - if not shot or self.blankForAskName then - -- nil, not false: the class default is inherited again, so a battle - -- that loses its arena mid-fight goes back to white voids - self.letterboxWhite = nil - self.dramaticShapeShot = nil - return innerDraw(self) - end - self.dramaticShapeShot = shot - -- The world reaches the screen through the seam a render pipeline's - -- finished world image already uses: one window-resolution canvas, - -- blitted a pixel to a pixel, with the 160x144 UI canvas composited over - -- it in the classic letterbox afterwards. That is what makes the backdrop - -- as crisp as the free-roam diorama while the pics and text stay GB art. - local renderer = game().renderer - if renderer and renderer.setWorldOverride then - renderer:setWorldOverride(shot.canvas) - end - -- beginFrame clears the UI canvas white for an opaque state; the world is - -- under it now, so clear it back to nothing and let it through. Safe to - -- do here: an opaque battle is the lowest state drawn, so nothing has - -- drawn into this canvas yet. - love.graphics.clear(0, 0, 0, 0) - -- the white letterbox exists so the window matches the white battle - -- canvas; there is a world out to the window edges now - self.letterboxWhite = false - OverworldBattle.drawHudPanels(self) - withoutBackgroundFill(self, innerDraw) - end - - -- The mons are geometry standing on the map now, drawn in the 3D pass - -- before this screen is composited at all, so the flat pics layer has - -- nothing left to do here. Skipped rather than left to draw underneath, or - -- every Pokemon would appear twice: once on its tile and once in its slot. - -- - -- Except under BACK SPRITES, where the player's side never became geometry and this - -- layer is the only thing that draws it. The engine's own onlySide argument - -- does the whole job: one call, the player's branches alone, in the slot and - -- at the scale the GB always put them -- feet on the box, 2x, back view. - innerPics = BattleState.drawPicsLayer - function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) - local shot = self.dramaticShapeShot - if not shot then - return innerPics(self, slide, sx, sy, onlySide, skipMenuClip) - end - if OverworldBattle.backPinned() and onlySide ~= "enemy" then - -- under the hour's own light, like everything else in the frame -- see - -- withTint, and the tint BattleScene hands over with the shot. - -- - -- Except on the wavy path, where the pic is baked into the GRAYSCALE bg - -- canvas for the zone pass to colour by region. That pass keys off the - -- red channel, and a night tint pulls red down -- it would not darken - -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a - -- second and the hour survives it fine. - local tint = not self.grayPics and shot.tint or nil - return withTint(tint, innerPics, self, slide, sx, sy, "player", - skipMenuClip) - end - end - - -- The battle's text box and its menus, over the frosted glass laid down for - -- them rather than over their own white paper. The INK is Gen 1's own black - -- and stays that way whatever is behind the glass -- the panel's tint is - -- what earns it its contrast (see BattleHud). - local innerText = BattleState.drawTextArea - function BattleState:drawTextArea() - if not self.dramaticShapeShot then return innerText(self) end - return withoutBoxFill(self, innerText) - end - - -- Move animations are authored against the pics' fixed slots, and a single - -- animation reaches across both sides, so there is no per-side offset to - -- give them. They ride the average, which is where the pair's centre went - -- -- a few pixels at most, and it keeps a hit landing on the mon it is - -- aimed at instead of drifting off it. - innerAnim = BattleState.drawAnimLayer - function BattleState:drawAnimLayer(colorized) - local shot = self.dramaticShapeShot - if not shot then return innerAnim(self, colorized) end - -- Move animations are authored against the pics' old fixed slots, and one - -- animation reaches across both sides, so there is no per-side offset to - -- give them. They ride to where the PAIR went: the midpoint of the two - -- mons' projected positions, less the midpoint of the slots they used to - -- sit in. A hit still lands on the mon it is aimed at. - -- - -- And they ride the pair's SEPARATION as well, because the mons - -- themselves do. Both are geometry standing on the map, so the camera - -- sizes them: zoom in and they grow, swing round to side-on and the two - -- marks close up as the axis foreshortens. A layer that only slid would - -- have held the authored 106-pixel spacing through all of it -- a beam - -- fired between two mons that are no longer that far apart, ending in - -- the air beside the one it was aimed at. Scaling about the same - -- midpoint keeps every authored offset the same fraction of the gap it - -- was authored as. - local a = OverworldBattle.ANCHOR - -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side - -- contributes no movement at all and the pair's centre has gone half as - -- far as the foe's mark did. - local px, py = shot.player[1], shot.player[2] - if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end - local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 - local ax = (a.enemy[1] + a.player[1]) / 2 - local ay = (a.enemy[2] + a.player[2]) / 2 - love.graphics.push() - love.graphics.translate(cx - ax, cy - ay) - -- Clamped, and skipped outright if the marks ever coincide: a - -- degenerate projection must leave the effects the size they were - -- rather than collapse them to nothing or blow them across the screen. - local k = OverworldBattle.animScale(shot, px, py) - if k ~= 1 then - love.graphics.translate(ax, ay) - love.graphics.scale(k, k) - love.graphics.translate(-ax, -ay) - end - local ok, err = pcall(innerAnim, self, colorized) - love.graphics.pop() - if not ok then error(err, 0) end - end - - -- The engine's flash has a SECOND half, and it is the one that reaches the - -- menu. Beside the white rectangle (dropped above) the flash moves are - -- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the - -- colorized pipeline applies in drawZonePass to the WHOLE background - -- canvas. That canvas carries the HUD glyphs and the text box, so a fade - -- meant for the two mons washed the menu out with them. - -- - -- The fade is left switched on for the pics, which read it through - -- picImage, and switched off for the zone pass alone. So the mons flash - -- and the furniture around them does not. - -- - -- The zone pass has a SECOND thing it paints, and this is the one that - -- reads as the menu box flashing. A screen shake makes it fill every zone - -- with the zone's own color 0 before it draws the offset copy -- the - -- hardware showing empty BG in the strip the shake vacated. On a white - -- battle field that fill is invisible; over a world it is an opaque white - -- sheet across the whole frame, and since a shake program alternates - -- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it - -- switches on and off a few times a second. It is dropped: the background - -- here is the map, so what the shake vacates should show the map. - local innerZone = BattleState.drawZonePass - function BattleState:drawZonePass(src, sx, sy) - if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end - -- shadow the method on the instance for this call only; putting the - -- field back to whatever it was (normally nil) lets the class method be - -- found again - local had = rawget(self, "activeBgp") - self.activeBgp = function() return nil end - local g = love.graphics - local rectangle = g.rectangle - g.rectangle = function(mode, ...) - -- the pass draws no other rectangle; the shake still shifts the copy - if mode == "fill" then return end - return rectangle(mode, ...) - end - local ok, err = pcall(innerZone, self, src, sx, sy) - g.rectangle = rectangle - self.activeBgp = had - if not ok then error(err, 0) end - end - - innerHUDs = BattleState.drawHUDs - function BattleState:drawHUDs(slide) - -- Normally the HUDs have already been drawn this frame, snapped out to the - -- window's edges and composited into the world image (snapHUDs). Drawing - -- them here as well would show each block twice, once in each place. - if self.dramaticShapeShot and snapped() then return end - return innerHUDs(self, slide) - end - - BattleState.dramaticShapeBattleHook = true -end - --- Whether each HUD block is on screen this frame. --- --- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam --- that reports "the enemy HUD is up". A panel under a HUD that is not there --- would be a frosted slab floating in the arena, so it is worth mirroring; --- the worst a future engine change can do is show an empty one for a frame, --- never break a battle. -function OverworldBattle.hudLive(battle, slide) - local enemy = battle.enemy and not battle.showEnemyTrainer - and not battle.enemySendingOut - and not battle:growInScale(battle.enemy) and slide == 0 - and not battle.enemy.fainted - local player = battle.player and not (battle.safari or battle.demo) - and not battle.showPlayerBack and slide == 0 - return enemy and true or false, player and true or false -end - --- ------- the snapped composite --- --- The engine's own HUD layer, rendered into a texture. --- --- One thing is falsified for the render, and it is falsified because this layer --- never reaches the battle's zone pass -- it is composited into the world image, --- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves --- the HP bar's fill as DMG gray for the zone pass to colour by region (#229); --- answered false, it tints its own greens and reds instead, exactly as it does --- on the flat path. --- --- Shadowed on the instance for this call only, the way drawZonePass shadows --- activeBgp: putting the field back to whatever it was (normally nil) lets the --- class method be found again. -function OverworldBattle.hudTexture(battle, slide) - if not (innerHUDs and battle) then return nil end - local had = rawget(battle, "colorMode") - battle.colorMode = function() return false end - local ok, layer = pcall(BattleHud.layerTexture, - BattleScene.GB_W, BattleScene.GB_H, - function() innerHUDs(battle, slide) end) - battle.colorMode = had - return ok and layer or nil -end - --- Draw both HUD blocks into the world image at the window's edges, each on its --- own frosted panel. Returns true when the frame's HUDs are up there and the --- in-frame draw must be skipped; false leaves the battle screen's own HUD --- exactly as it was before any of this existed. --- --- Both bands are blitted whether or not that side's HUD is LIVE, because a band --- carries more than the HUD: the pokeball rows of the intro and of an enemy --- faint, and the safari ball count, all draw in these rows and belong at the --- same edge as the block they share it with. The panels are the ones that --- follow hudLive -- frosted glass under nothing is a slab floating in the arena. -function OverworldBattle.snapHUDs(battle, shot) - if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then - return false - end - -- With a headset live the HUDs stay IN the GB frame -- the classic - -- slots, on the glass drawHudPanels lays for the unsnapped path. Both - -- of VR's battle screens (the floating panel and the pokedex's) crop - -- to the letterbox, and a block snapped out to the window's edge would - -- be cropped away with the window around it. - local okV, vr = pcall(V.require, "VR") - if okV and vr and vr.active and vr.active() then return false end - local slide = (battle.introSlide or 0) * 4 - local rects, bandX = OverworldBattle.snapRects(shot) - local enemy, player = OverworldBattle.hudLive(battle, slide) - local live = {} - if not isIOS() then - if enemy then live.enemy = rects.enemy end - if player then live.player = rects.player end - end - -- The text box's frost panel normally goes into this same world-canvas pass. - -- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating - -- the large ghost rectangle behind the Pokemon. Keep the box border/text but - -- skip only this frosted backing on iOS. - if not isIOS() then - for key, rect in pairs(OverworldBattle.textRects(battle)) do - live[key] = toWorld(rect, shot) - end - end - local layer = OverworldBattle.hudTexture(battle, slide) - if not layer then return false end - - local g = love.graphics - local prevCanvas = g.getCanvas() - local prevBlend, prevAlpha = g.getBlendMode() - local ok, err = pcall(function() - g.setCanvas(shot.canvas) - g.setBlendMode("alpha") - for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end - g.setColor(1, 1, 1, 1) - for side, band in pairs(OverworldBattle.HUD_BAND) do - local quad = g.newQuad(band[1], band[2], band[3], band[4], - BattleScene.GB_W, BattleScene.GB_H) - local x = bandX[side] + band[1] * shot.scale - local targetY = shot.ly + band[2] * shot.scale - - if isIOS() then - -- Keep the player's HUD exactly where it currently appears on the - -- right. Only the enemy band needs its mirrored destination corrected. - local y = targetY - if side == "enemy" then - y = shot.ph - targetY - band[4] * shot.scale - end - - -- iOS presents this Canvas-to-Canvas HUD texture upside down. - g.draw(layer, quad, x, y, 0, - shot.scale, -shot.scale, 0, band[4]) - else - g.draw(layer, quad, x, targetY, 0, - shot.scale, shot.scale) - end - end - end) - if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end - g.setBlendMode(prevBlend or "alpha", prevAlpha) - g.setColor(1, 1, 1, 1) - if not ok then error(err, 0) end - return true -end - --- Lay the frosted glass down under whichever HUD and box are about to draw, --- and record which way the glyphs have to flip. --- --- The panels are the fallback path only: normally the HUDs are snapped out to --- the window's edges and their glass, and the box's, went into the world image --- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is --- drawn here, in the GB frame, whichever path laid the glass under it. -function OverworldBattle.drawHudPanels(battle) - local shot = battle.dramaticShapeShot - if not shot then return end - if snapped() then - return - end - local slide = (battle.introSlide or 0) * 4 - local enemy, player = OverworldBattle.hudLive(battle, slide) - local rect = OverworldBattle.HUD_RECT - local live = {} - if enemy then live.enemy = rect.enemy end - if player then live.player = rect.player end - for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end - if not next(live) then return end - for _, r in pairs(live) do BattleHud.panel(r, shot) end -end - -return OverworldBattle +-- Overworld battles: fights that happen on the map you were standing on. +-- +-- The engine's battle is a screen: a white field with two pics on it, pushed +-- over a frozen overworld that stops drawing. This turns that white field +-- into the world -- the same terrain the free-roam mode extrudes, shot from +-- a placed over-the-shoulder camera at a clear patch of ground nearby -- +-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move +-- animation, faint slide and text box is the engine's own, drawn in the +-- engine's own order. What changes is what is behind them, and where the two +-- pics stand. +-- +-- The sequence, from the moment something picks a fight: +-- +-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe +-- plays over an empty map and no bystander is left standing in the +-- arena shot +-- 2. the engine's own transition wipes the screen (untouched: it is the +-- right wipe, picked by the right three bits) +-- 3. the battle draws over a live, window-resolution render of the arena, +-- with each mon PINNED to the cell it is standing on, the camera +-- drifting slowly enough to read as parallax, and a depth-of-field pass +-- holding the slab of world the two of them occupy sharp +-- 4. the battle ends, the cast comes back, and the player is exactly +-- where they were standing +-- +-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the +-- player goes: nothing here writes a cell, a facing, a flag or a warp. A +-- real warp would have to survive trainer sight-lines, post-battle +-- dialogue, the blackout path and every script that assumes the player is +-- where it left them -- and it would have to put them back afterwards. +-- Moving the camera buys the whole shot and owes nothing back. +-- +-- The feature declines cleanly rather than half-working: no depth support, +-- no open ground on the map, the row switched off, or a mesh still building +-- all end at the same place, which is the battle screen the engine has +-- always drawn. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local BattleArena = V.require("BattleArena") +local BattleCam = V.require("BattleCam") +local BattleScene = V.require("BattleScene") +local BattleDOF = V.require("BattleDOF") +local BattleHud = V.require("BattleHud") +local BattlePics = V.require("BattlePics") +local Voxel3D = V.require("Voxel3D") +local ChunkMesher = V.require("ChunkMesher") + +local OverworldBattle = {} + +-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a +-- second, which is how the glyph flip is checked from a shot run. Read +-- through pcall: the loader's sandbox does not hand a mod `os`, and a +-- diagnostic must never be the reason the mod fails to load. +local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end)) +if DEBUG == nil or DEBUG == false then DEBUG = nil end + +OverworldBattle.KEY = "battles" +OverworldBattle.LABEL = "3D-BTL" + +-- Five rungs. Two independent choices, laid out as one ladder because they +-- are one question to the player -- WHAT is standing there, and WHERE: +-- +-- on the MAP on two DISCS +-- pics 2D-3D A 2D-3D B +-- models STADIUM A STADIUM B +-- +-- 2D-3D A the mode this file was written for: the fight is staged on +-- the map and the two Pokemon are the GB's OWN PICS, stood up +-- on their tiles as quads (BattleBillboard). +-- 2D-3D B those same pics on a pair of DISCS against the sky, with no +-- map at all (see lib/StadiumStage.lua). The Game Boy's own +-- framing with the Game Boy's own art, in three dimensions -- +-- and, like every B rung, it works everywhere, including the +-- caves and shop floors that have nowhere to stage a fight. +-- STADIUM A the staged fight with the Pokemon Stadium battle models in +-- place of those quads -- skinned, animated, and playing the +-- animation the move being used actually calls for (see +-- lib/Stadium.lua). The world is still the world: the fight +-- happens on real ground, in the map's own weather and light. +-- STADIUM B the models on the discs: both halves swapped at once. +-- OFF the engine's own white battle screen. +-- +-- A and B is the STAGE and it is the same stage either way -- the discs do +-- not know what is standing on them and BattleScene draws them off +-- `arena.discs` alone, which is why the second column cost a value in this +-- table and nothing else. The four combinations are all reachable rather +-- than only the diagonal, because a player who cannot use the STADIUM rungs +-- -- no ROM, or a ROM they would rather not go and find -- should still be +-- able to have the disc framing, and because the discs are the answer to +-- "this map has nowhere to fight" whichever art is standing on them. +-- +-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and +-- what an unrecognised stored value falls back to, and the stored value for +-- this row has been `true` since the row existed. Keeping `true` at the head +-- means every save written before the later rungs existed reads back as the +-- 2D-3D it was written for, and a mod whose headline is "the world in 3D" +-- still does not need the player to go and find the switch. +-- +-- Every other stored value is likewise the one it has always been -- +-- "stadium" from before there was a B, "stadiumB" from before there was a +-- flat one -- so no save loses the mode it chose. +-- +-- Both STADIUM rungs are GATED on the models existing: the mod ships no +-- Pokemon Stadium data, and until the player's own ROM has been found and +-- built from (StadiumInstall) the row simply has two fewer stops. See +-- ModSetting.setGate for why they are skipped rather than shown and refused. +-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are +-- the game's own art, so it needs nothing the base game did not ship. +OverworldBattle.FLAT_B = "flatB" + +OverworldBattle.setting = + ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, + { true, "flatB", "stadium", "stadiumB", false }, + { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" }) + :setGate(function(value) + if value ~= "stadium" and value ~= "stadiumB" then return true end + local ok, install = pcall(V.require, "StadiumInstall") + return ok and install and install.available() + end) + +-- Whether the fight stands on the two carried DISCS rather than on the map +-- -- the B column above, whichever row of it. Asked by stageFor (what to +-- stage on), wantsFront (whether this map needs an arena at all) and, once +-- the arena carries the answer as `arena.discs`, by BattleScene and +-- VoxelScene for what to draw. +-- +-- Read straight off the row rather than through Stadium, because it is a +-- question about the STAGE and half the rungs that answer yes have no +-- Stadium models on them at all. +function OverworldBattle.discs() + local value = OverworldBattle.setting:get() + return (value == OverworldBattle.FLAT_B or value == "stadiumB") +end + +-- Whether the VR row is ON -- read lazily, because VR requires modules +-- that sit above this one. While it is, this mode stops being optional: +-- the headset's battle seat, the pokedex screen and the effects plane +-- all assume a fight standing on the world, and a white-field battle +-- inside a headset is exactly the flat screen VR exists to replace. +local function vrOn() + local ok, vr = pcall(V.require, "VR") + return ok and vr and vr.enabled and vr.enabled() or false +end + +function OverworldBattle.enabled() + if vrOn() then return true end + return OverworldBattle.setting:get() and true or false +end + +-- Whether the STADIUM rung is the one selected -- read through Stadium so +-- there is one answer to that question and it lives with the mode it +-- describes. Required lazily: Stadium sits above this file and requires it +-- back (for the row), which a load-time require would deadlock. +function OverworldBattle.stadium() + local ok, stadium = pcall(V.require, "Stadium") + return (ok and stadium and stadium.enabled()) and true or false +end + +-- ------- BACK SPRITES: the player's own mon stays on the menu +-- +-- The staged shot stands BOTH mons on the map, which is the mode's whole +-- claim -- but it costs the one piece of framing Gen 1 is most recognisable +-- by: your own Pokemon, seen from behind, sitting on top of the battle menu +-- with its feet on the box. That silhouette is the series' shot. +-- +-- So BACK SPRITES is offered as a middle setting rather than a compromise +-- imposed on everyone. With it on the foe is still geometry standing on its +-- tile at the far end of the arena, and the player's side goes back to being +-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same +-- feet on row 96. +-- Nothing else about the shot moves -- the arena, the camera and the drift are +-- solved exactly as they were, so the foe stands where it always stood and the +-- player's cell is simply empty ground in the foreground. +-- +-- OFF by default: what the mode advertises is the pair of them out there. +OverworldBattle.BACK_KEY = "battleBack" +OverworldBattle.BACK_LABEL = "BACK SPRITES" + +OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, + OverworldBattle.BACK_LABEL, + { false, true }, { "OFF", "ON" }) + +-- Gated on 3D-BTL rather than read alone: with staged battles off there is no +-- staged shot for a back pic to be pinned in FRONT of, and the engine's own +-- battle screen already draws exactly this. And held OFF under VR: the +-- headset stands both mons on the world -- a flat back pic pinned to the +-- 2D frame would keep your own mon off the arena the battle seat looks at. +function OverworldBattle.backPinned() + if not OverworldBattle.enabled() then return false end + if vrOn() then return false end + return OverworldBattle.backSetting:get() and true or false +end + +-- Whether a pic is the one drawn in the GB's own slot with its feet on the +-- text box, rather than geometry standing out on the map. +-- +-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back +-- that holds the slot until "Go!" -- because that is the only pic this mod +-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its +-- tile whichever mode is on, and with the mode off the player's side is one +-- too, so both of those keep the open bottom that lets the arena through a +-- stride. What the answer buys is in BattlePics: a pic on the box has nothing +-- behind its lowest row, so its bottom edge seals. +-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the +-- side it is not rendering by setting the field to FALSE (see OFF) and holds +-- it that way for the whole render -- during which the pic layer runs, and +-- picImage asks this. A nil test passes a `false` straight through to the +-- index below, and the error comes out of sideTexture into the pcall that +-- calls it: the foe's billboard is dropped for the frame and the Pokemon +-- simply is not there. +function OverworldBattle.pinnedPic(battle, img) + if not (battle and img) then return false end + if not OverworldBattle.backPinned() then return false end + if img == battle.playerBackPic then return true end + local player = battle.player + return (player and img == player.sprite) and true or false +end + +-- ------- both mons face you +-- +-- Standing on a map, seen from in front, a Pokemon showing you its BACK is +-- wrong twice over: it is turned away from the camera that is looking at it, +-- and the back pics are a different, smaller drawing made for a slot the +-- player never really sees. So the player's side asks for the FRONT pic too, +-- through the engine's own pokemon.sprite hook -- the seam that exists for +-- exactly this, so no battle code has to be touched to get it. +-- +-- Unless BACK SPRITES is on, the setting that asks for the back pic back: +-- that mon is drawn in its own slot on the menu, seen from behind, and the +-- front art would be it turned round to face the player it belongs to. +-- +-- Answered BEFORE a battle exists, because the battler is built before the +-- battle is pushed. So it cannot ask whether this fight is staged; it asks +-- whether one on this map WOULD be -- the row is on, the 3D pass is +-- available, and the map has an arena -- which is the same question with the +-- same answer a moment later. Cached per map, because the arena search walks +-- the whole grid and this runs once per battler. +local staged = { mapId = nil, ok = false } + +function OverworldBattle.wantsFront() + if not OverworldBattle.enabled() then return false end + if OverworldBattle.backPinned() then return false end + if not Voxel3D.available() then return false end + -- required here rather than through the file's own helper: this runs + -- while a battler is being built, which is before that helper is defined + local g = require("src.core.Game") + local ow = g and g.overworld + if not (ow and ow.map and ow.player) then return false end + -- a B rung carries its own stage, so the answer is yes on every map and + -- there is nothing to search or to cache + if OverworldBattle.discs() then return true end + if staged.mapId ~= ow.map.id then + local ok, arena = pcall(BattleArena.find, ow.map, + ow.player.cellX, ow.player.cellY, + ow.player.surfing) + staged = { mapId = ow.map.id, ok = (ok and arena) and true or false } + end + return staged.ok +end + +-- ------- where the engine's own pics stand +-- +-- The GB draws the player's back pic with its feet on the text box at row 96 +-- and its 7x7-tile slot centred on x=40, and the enemy's front pic +-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two +-- points are the pics' FEET, they hold for every species at every scale (the +-- engine's placement helpers pin the bottom edge and the centre), and they +-- are what BattleCam is solved to put the two arena cells under. +-- +-- Which makes the pin a subtraction: whatever the drift has done to the +-- camera this frame, each pic moves by its own cell's projected position +-- minus its anchor. At the middle of the drift that is zero. +OverworldBattle.ANCHOR = { + player = { 26, 96 }, + enemy = { 124, 56 }, +} + +-- ------- how big a mon is +-- +-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for +-- a 56px front pic, 2x for a 32px back one -- because that is the only way it +-- keeps every pixel the artist drew, and the CAMERA is solved so that one +-- overworld square is that big on screen (see BattleCam). The mon fits its +-- tile because the tile was sized to the mon, not the other way round. +OverworldBattle.SLOT_W = { front = 56, back = 32 } + +-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and +-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at +-- the anchors above. +OverworldBattle.HUD_RECT = { + enemy = { 8, 0, 80, 32 }, + player = { 72, 56, 88, 40 }, +} + +-- ------- the box at the bottom, on the same glass +-- +-- The HUDs got frosted panels because black glyphs on grass are not readable. +-- The battle's text box and its menu had the opposite problem and the same +-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was +-- the field's own colour when the field was white and is a sheet of paper laid +-- over the bottom third of the diorama now that it is not. +-- +-- So the box gets exactly what the HUDs get: the world behind it, blurred to +-- frosted glass and laid back down translucent, with the border and the text +-- drawn over it unchanged, and the same brightness verdict flipping the ink +-- when the ground under it is dark. Only the FILL is taken away -- every glyph +-- the engine draws inside the box is still the engine's own, in its own place. +-- +-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects. +-- READ-ONLY duplicates of that function's own branches, the same kind of +-- mirror hudLive is and for the same reason: there is no seam that reports "a +-- move menu is up", and glass has to go down BEFORE the box that sits on it. +-- The worst a future engine change can do is frost a rectangle nothing lands +-- on, or leave a box unfrosted -- never break a battle. +-- +-- Each rect stops where the next one starts rather than overlapping it: two +-- panels over the same pixels would frost it twice and leave a visible step +-- along the seam. +OverworldBattle.TEXT_RECT = { + box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always + -- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows + -- above the box above -- its last tile row sits inside that one + moves = { 0, 64, 88, 32 }, + -- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way + mimic = { 0, 56, 128, 40 }, +} + +-- How far apart the two anchors are: the spacing every move animation was +-- authored against, and so the yardstick the live pair is measured with. +OverworldBattle.ANCHOR_SPAN = math.sqrt( + (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2 + + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2) + +-- The effects layer's scale for this shot: how far apart the two mons +-- actually are on screen, over how far apart the slots they were authored +-- for were. Clamped hard at both ends -- an effect is pixel art and a wild +-- factor is worse than a slightly wrong one -- and held at exactly 1 when +-- the marks coincide, which is a projection about to degenerate rather +-- than a pair that has genuinely closed up. +OverworldBattle.ANIM_SCALE_MIN = 0.5 +OverworldBattle.ANIM_SCALE_MAX = 2.0 + +function OverworldBattle.animScale(shot, px, py) + if not (shot and shot.enemy and px and py) then return 1 end + local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py + local span = math.sqrt(dx * dx + dy * dy) + if not (span > 1) then return 1 end + local k = span / OverworldBattle.ANCHOR_SPAN + return math.max(OverworldBattle.ANIM_SCALE_MIN, + math.min(OverworldBattle.ANIM_SCALE_MAX, k)) +end + +function OverworldBattle.textRects(battle) + if not battle or battle.blankForAskName then return {} end + -- a capture session aiming has no text to put in the box and takes it + -- off the frame (see the drawTextArea wrap): no box, no glass under it + local cap = BattleScene.capture + if cap and cap.hideTextBox then return {} end + local r = OverworldBattle.TEXT_RECT + local out = { box = r.box } + if battle.phase == "moveSelect" then + out.moves = r.moves + elseif battle.phase == "mimicSelect" then + out.mimic = r.mimic + end + return out +end + +-- ------- the HUDs, out at the window's own edges +-- +-- The battle screen is 160x144 in the MIDDLE of the window and the world is the +-- whole of it. That left both HUD blocks huddled together in the middle of the +-- frame with map showing on either side of them, which reads as a Game Boy +-- screenshot pasted over a diorama rather than as the diorama's own furniture. +-- +-- So each block is snapped to its own side: the foe's to the left edge of the +-- window, the player's to the right. Nothing about either block changes -- same +-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar +-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape +-- of the GB screen there is nowhere to go and the snap is a no-op. +-- +-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI +-- canvas and everything outside it is clipped away. So the layer is rendered to +-- a texture and composited into the WORLD image instead, which is the one +-- surface in this mode that covers the whole window. + +-- The rows each block is cut out of, full width. Generous on purpose: +-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches +-- further than the panel does, and the pokeball rows and the safari ball count +-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies +-- outside rows 0-96, and the two bands split that between them. +OverworldBattle.HUD_BAND = { + enemy = { 0, 0, 160, 48 }, + player = { 0, 48, 160, 48 }, +} + +-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted +-- glass is cut to, plus the x its band is blitted at. +-- +-- The foe's panel starts at the window's left edge and the player's ends at the +-- right one. The vertical is untouched, so both stay on the rows the GB put +-- them on. A band's own origin sits outside the window by the panel's inset -- +-- the couple of pixels a HUD shake can push past the edge are clipped there, +-- which is the whole cost of the snap and is invisible. +function OverworldBattle.snapRects(shot) + local s = shot.scale + local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player + local ex = -e[1] * s -- foe: panel's left edge to 0 + local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side + local rects = { + enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, + player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s }, + } + return rects, { enemy = ex, player = px } +end + +-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox +-- blit will actually put it. The text box has not moved anywhere -- it is drawn +-- where it always was -- but its glass is laid into the world image alongside +-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a +-- pixel to a pixel rather than magnified out of a 160x144 canvas. +local function toWorld(rect, shot) + local s = shot.scale + return { shot.lx + rect[1] * s, shot.ly + rect[2] * s, + rect[3] * s, rect[4] * s } +end + +-- ------- the live battle +-- +-- nil when no overworld battle is running. Never more than one: battles do +-- not nest. +local session = nil + +local function isIOS() + return love.system and love.system.getOS and love.system.getOS() == "iOS" +end + +local function game() + return require("src.core.Game") +end + +-- Whether this frame's HUDs went out to the window's edges instead of being +-- drawn in the GB frame. False whenever the composite could not be made, which +-- is what leaves the in-frame HUD as the fallback rather than no HUD at all. +local function snapped() + return (session and session.snapped) and true or false +end + +-- Put the map's cast back. Both lists are handed back by identity, so +-- anything that captured one before the battle still sees the same table. +local function restoreCast() + if not (session and session.state) then return end + if session.entities then session.state.entities = session.entities end + if session.ghosts then session.state.ghosts = session.ghosts end + session.entities, session.ghosts = nil, nil +end + +-- Cull them. The player stays -- they are not an NPC, they are who the +-- battle belongs to, and Fly/surf animations and the save's own capture read +-- state.player through this list. +-- +-- Only the DRAW lists are touched, and only while the overworld is frozen +-- underneath a battle: StateStack updates the top state alone, so nothing +-- walks, wanders, triggers or collides against a list that is short for +-- these frames. The originals go back at battle.ended. +local function cullCast(state) + session.entities = state.entities + session.ghosts = state.ghosts + state.entities = { state.player } + state.ghosts = {} +end + +-- ------- one right battle layout +-- +-- Everything this file composes is measured in the GB's own 160x144 frame: the +-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs +-- the frosted panels are cut to, and the full-frame white intercepted to let +-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a +-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those +-- -- the mons would stand where no camera was solved for them, and the panels +-- would land beside the HUDs they are supposed to be under. +-- +-- So while a fight can be staged on the map there is one right answer, and it +-- is SET rather than worked around. The engine reads the option live +-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the +-- top state for its surface the same way), so writing it here lands on the +-- battle being pushed as well as every one after it. +-- +-- This is the last line rather than the first: the OPTIONS menu takes the row +-- off the list and pins the value while 3D-BTL is on (see main.lua), so a +-- player is never offered a switch that gets reverted under them. What reaches +-- here is a value that arrived some other way -- a save written before the mod +-- was installed, the mod manager's own page, another mod. +function OverworldBattle.forceOG(g) + g = g or game() + local opts = g and g.save and g.save.options + if not opts or opts.battleLayout ~= "wide" then return false end + opts.battleLayout = "og" + if g.writeOptions then pcall(g.writeOptions, g) end + return true +end + +-- Where THIS fight stands, on whichever rung is running: the map's own +-- ground, or the pair of discs a B rung carries with it. +-- +-- The one place the two columns actually diverge, and it is worth stating +-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a +-- map whose authored entry is a refusal -- and the battle then plays exactly +-- as the vanilla game does. A B rung cannot fail: its stage is not something +-- the map has to have room for, so a fight in the tightest cave in Kanto is +-- staged as readily as one on Route 1. +function OverworldBattle.stageFor(state) + if OverworldBattle.discs() and Voxel3D.available() then + local okStage, arena = pcall(function() + return V.require("StadiumStage").arena(state.map) + end) + if okStage and arena then return arena end + -- the discs could not be built; fall through to the map, which is a + -- worse picture but a real one + end + local okFind, arena = pcall(BattleArena.find, state.map, + state.player.cellX, state.player.cellY, + state.player.surfing) + return (okFind and arena) or nil +end + +-- Stage a battle triggered from `state`, if this mode can. Returns true when +-- a session started -- which is also the only case where anything visible +-- changes, so a map with no room for an arena plays exactly the vanilla +-- battle it always did, cast and all. +function OverworldBattle.begin(state, battle) + OverworldBattle.finish() + if not OverworldBattle.enabled() then return false end + if not (state and state.map and state.player) then return false end + if not Voxel3D.available() then return false end + + local arena = OverworldBattle.stageFor(state) + if not arena then return false end + + -- the fight is staged from here on, so the layout it is composed for is not + -- optional any more (see forceOG) + OverworldBattle.forceOG() + + session = { state = state, arena = arena, battle = battle, shot = nil, + armed = false, token = 0 } + cullCast(state) + BattleCam.reset() + -- and, on the STADIUM rung, the pair of models that will stand on this + -- arena's two cells. Declines quietly on any other rung. + pcall(function() V.require("Stadium").begin(arena) end) + return true +end + +-- The fallback entry point: a battle that arrived without going through the +-- overworld's own pushBattle (a link battle, a script pushing a BattleState +-- directly). Nothing visible depends on the cull for those -- the wipe has +-- already been and gone -- but the arena still has to be picked. +function OverworldBattle.ensure(battle) + if session then + -- a battle pushed through the overworld reaches begin() before it is + -- built far enough to draw; battle.started is where it is finished + if battle and not session.battle then session.battle = battle end + return + end + local g = game() + local ow = g and g.overworld + if ow and ow.map then OverworldBattle.begin(ow, battle) end +end + +-- The arena this battle is staged on, or nil. Read by the shot driver so a +-- screenshot can be labelled with the ground it was taken on. +function OverworldBattle.arena() + return session and session.arena or nil +end + +function OverworldBattle.finish() + if not session then return end + restoreCast() + session = nil + Voxel3D.camera = nil + pcall(function() V.require("Stadium").finish() end) +end + +-- ------- per-frame +-- +-- Driven from the voxel pipeline's update hook, which the engine ticks every +-- frame regardless of which state is on top -- including the frames the +-- transition wipe covers, which is what gets the arena's meshes built before +-- the first battle frame needs them. +-- +-- The scene is rendered HERE rather than inside the battle's draw, because +-- update runs with no canvas bound: a 3D pass that binds a depth target and +-- unbinds to the screen when it is done cannot do that in the middle of +-- someone else's frame without putting the frame back itself. +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 + -- A battle that ended without saying so (a script tearing the state down, + -- a path that never emits battle.ended) would otherwise leave the cast + -- culled for good. Armed only once something has actually covered the + -- overworld, because begin() runs while the overworld is still on top. + if top ~= nil and top ~= ow then + session.armed = true + elseif session.armed then + OverworldBattle.finish() + return + end + + -- Whether the shot is the player's to steer at all. BACK SPRITES pins + -- their own mon to the GB's slot on the menu while the foe stands out on + -- the map, and there is no angle that half-framed, half-solid + -- composition survives -- so under it the camera holds the shot the rig + -- was solved for (the slow drift aside, which was always there). Polled + -- per frame rather than latched at battle start: the row is reachable + -- from the mod manager's page mid-session. + -- A LET'S GO capture session holds it too: the throw is aimed in this + -- exact framing, and a camera that moved under a ball in flight would + -- bend where the flick was pointed after the fact. (The session also + -- sets BattleCam.still, which is what stops the drift -- see + -- CatchThrow.begin.) + BattleCam.steerable = not OverworldBattle.backPinned() + and not BattleScene.capture + -- the right stick, read as a rate before the rig is built from it: the + -- wheel, the keys, the mouse and a drag all arrive as events and have + -- already landed, but a stick is a HELD position and only a tick can + -- turn it into travel (CamControl, which owns every one of those inputs) + pcall(V.require("CamControl").tick, dt) + BattleCam.update(dt) + -- the battle only exists once it has been pushed; a session opened at + -- pushBattle time has it, one opened from battle.started was handed it + session.battle = session.battle or (top ~= ow and top or nil) + -- the world pass is hidden behind the battle, so mesh builds get the wide + -- slice: nothing visible can hitch on them + ChunkMesher.pump(true) + + -- The STADIUM models, ahead of the pics, because what they decide is + -- WHICH pics are needed: a side a model is standing on gets no billboard + -- texture rendered for it at all (see Stadium.covers). Posed and skinned + -- here too, once for the frame -- the sun pass, the camera and, in a + -- headset, both eyes all draw the same skinned meshes. + pcall(function() + local host = (session.arena and session.arena.map) or session.state.map + V.require("Stadium").update(dt, session.battle, + BattleScene.groundY(host, session.arena)) + end) + + -- The mons' textures are rendered HERE, with no canvas bound, for the same + -- reason the scene is: the pics layer binds its own targets, and doing that + -- inside somebody else's frame means putting the frame back afterwards. + local okTex, textures = pcall(OverworldBattle.textures, session.battle) + if not okTex then textures = nil end + -- stashed for the VR eye pass, which stands these same pics on the map + -- in ITS view of the world (VoxelScene's eyes path). Stashed HERE + -- because rendering them binds canvases, which the eye pass -- mid-scene + -- when it wants them -- must never do; reading a stashed canvas is free. + session.textures = textures + -- and the move-animation layer, for the same eyes -- rendered only + -- while a headset is actually watching, because only the VR world + -- pass draws it (the flat screen has the animations in-frame already) + session.animTex = nil + local okVR, vrOn = pcall(function() + local vr = V.require("VR") + return vr.active and vr.active() or false + end) + if okVR and vrOn and session.battle then + local okA, anim = pcall(OverworldBattle.animTexture, session.battle) + if okA then session.animTex = anim end + end + session.token = (session.token or 0) + 1 + local ok, shot = pcall(BattleScene.render, session.state, session.arena, + textures, session.token) + if not ok then + -- One failure retires the arena for THIS battle and nothing else: the + -- battle screen carries on as the engine's own, the free-roam pipeline + -- this runs inside keeps rendering the overworld, and the next battle + -- tries again. Rethrowing would hand the whole voxel mode to Pipelines' + -- guard, which retires a pipeline for the session. + session.shot = nil + session.snapped = false + session.broken = true + V.mod.log:warn("overworld battle scene failed: %s -- this battle draws " + .. "on the plain battle background", tostring(shot)) + return + end + session.snapped = false + if shot and shot.canvas then + -- the depth of field is measured off the two marks: the slab in focus is + -- the one the mons are standing in, at whatever the drift has done to + -- where that lands + local y1 = shot.ly + shot.player[2] * shot.scale + local y2 = shot.ly + shot.enemy[2] * shot.scale + local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph) + local okDof, blurred = pcall(BattleDOF.apply, shot.canvas, + focusY, band, range) + if okDof and blurred then shot.canvas = blurred end + -- the frosted glass the HUDs sit on is built from the FINISHED backdrop, + -- so a panel over a blurred far field is frosted from what is actually + -- behind it + pcall(BattleHud.build, shot.canvas) + -- and then the HUDs go ON that backdrop, snapped out to the window's own + -- edges (snapHUDs). Here rather than in the battle's draw for the same + -- reason the scene is: it binds a canvas of its own. After the frost, so + -- the glass is frosted from the world alone and never from the glyphs + -- about to sit on it. + local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + session.snapped = (okHud and up) and true or false + -- once per battle, not once per frame: a driver that cannot do this cannot + -- do it sixty times a second either, and the fallback is silent and fine + if not okHud and not session.hudWarned then + session.hudWarned = true + V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " + .. "in the battle frame this battle", tostring(up)) + end + end + session.shot = shot +end + +-- The finished shot for this frame, or nil when there is none and the battle +-- should draw the way it always did. +function OverworldBattle.shot() + if not session or session.broken then return nil end + local s = session.shot + if s and s.canvas then return s end + return nil +end + +-- The staged fight's WORLD-side pieces, for a pass that stands the mons in +-- its own view of the map rather than in the arena's composed shot -- the +-- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed +-- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets +-- per-eye cards), the live textures table (for the hit-flash flag), and the +-- token the shadow signature keys on. nil while nothing is staged, the +-- arena is broken, or the pics have not been rendered yet. +function OverworldBattle.worldCards() + if not (session and session.arena and not session.broken) then return nil end + local tex = session.textures + if not tex then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + local groundY = BattleScene.groundY(host, session.arena) + return BattleScene.monCards(session.arena, groundY, tex), tex, session.token +end + +-- The live session's BATTLE STATE, once the pushed battle has been met +-- (session.battle fills in from the stack in update). The VR quad reads +-- it to tell "the battle screen is on top" from "a menu is over the +-- battle" -- the UI-only panel is right for the first and wrong for the +-- second. nil with no session, a broken one, or a battle not yet pushed. +function OverworldBattle.battle() + if not (session and not session.broken) then return nil end + return session.battle +end + +-- The staged fight's arena and floor height, for the capture mode: the +-- foe's world cell is the far end of the throw and the floor is what a +-- short ball bounces on. nil whenever there is nothing staged, which is +-- one of the gates that sends a throw back to the engine's own toss. +function OverworldBattle.arenaInfo() + if not (session and session.arena and not session.broken) then return nil end + local host = session.arena.map or (session.state and session.state.map) + if not host then return nil end + return session.arena, BattleScene.groundY(host, session.arena) +end + +-- The foe's rendered pic texture, for the capture mode's ring. The mark +-- BattleScene pins is the CELL's ground point, but a species' art sits +-- wherever the artist drew it in the frame -- a bird hovers half a slot +-- above its own feet row -- and a timing ring belongs on the CREATURE, +-- not on its patch of grass. The capture session reads this canvas back +-- once and centres the ring on the art's opaque box. nil on the STADIUM +-- rungs (the foe is a model, no pic is rendered) and before the first +-- textures pass, both of which the caller treats as "use the heuristic". +function OverworldBattle.enemyTexture() + if not (session and session.textures) then return nil end + return session.textures.enemy +end + +-- The move-animation layer as a texture: the engine's own drawAnimLayer, +-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized +-- transparent canvas of its own. This is what stands the effects up in +-- the VR eyes' world -- see worldAnim below -- the same move the pics +-- made through sideTexture: let the engine draw what it always draws, +-- catch it on a canvas, stand the canvas in the scene. +local animLayer = nil +-- the engine's own drawAnimLayer, captured by install(). Declared HERE, +-- above the function that reads it: a local declared further down the +-- chunk would leave this function reading a global of the same name -- +-- nil forever, and the effects silently absent from the eyes (the bug +-- this comment is the tombstone of). +local innerAnim = nil + +function OverworldBattle.animTexture(battle) + if not (innerAnim and battle) then return nil end + if not (love.graphics and love.graphics.newCanvas) then return nil end + if not animLayer then + local ok, c = pcall(love.graphics.newCanvas, + BattleScene.GB_W, BattleScene.GB_H) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + animLayer = c + end + local g = love.graphics + local prevCanvas = g.getCanvas() + local ok = pcall(function() + g.push("all") + g.origin() + g.setCanvas(animLayer) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + innerAnim(battle, false) + g.pop() + end) + if not ok then pcall(g.pop, g) end + if prevCanvas then pcall(g.setCanvas, g, prevCanvas) + else pcall(g.setCanvas, g) end + return ok and animLayer or nil +end + +-- The staged fight's effects, for the VR eyes: the animation layer plus +-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit +-- authored at a slot lands on the mon standing in for that slot). nil +-- while nothing is staged or no layer was rendered this frame. +function OverworldBattle.worldAnim() + if not (session and session.arena and not session.broken) then return nil end + local tex = session.animTex + if not tex then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + local groundY = BattleScene.groundY(host, session.arena) + local model = BattleScene.fxCard(session.arena, groundY, + OverworldBattle.ANCHOR) + if not model then return nil end + return tex, model +end + +-- Where the staged fight STANDS -- the arena and its floor height -- for a +-- camera that wants to look at it rather than draw it (the VR battle +-- mount). Answered as soon as the stage exists, textures or not: the +-- camera should be seated behind the fade before the first pic lands. +-- nil whenever no fight is staged on the world. +function OverworldBattle.stage() + if not (session and session.arena and not session.broken) then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + return session.arena, BattleScene.groundY(host, session.arena) +end + +function OverworldBattle.invalidate() + BattleDOF.invalidate() + BattleHud.invalidate() + BattlePics.invalidate() + -- the STADIUM models hold meshes and textures of this graphics context + -- like everything else here does + pcall(function() V.require("Stadium").invalidate() end) +end + +-- ------- the battle screen's background +-- +-- BattleState opens by filling 160x144 white -- that fill IS the battle's +-- background, and in the colorized pipeline it is also the BG canvas's clear +-- (nothing else clears it, so skipping it outright would ghost last frame). +-- So for the length of one draw, that one call is intercepted: on the two +-- offscreen canvases it becomes a transparent clear, so the shade-remap pass +-- composites the HUD and the text box over the arena and leaves the empty +-- field showing it; on the screen it is simply dropped, because the UI canvas +-- has already been cleared transparent for the world to show through. +-- +-- Matched exactly -- fill, the full frame, at the origin, in opaque white -- +-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the +-- move-animation flash (which is white at 0.85) all pass through untouched. +-- +-- This is a shim over love.graphics and it is the one invasive thing here, +-- so it is scoped as tightly as it can be: installed around a single call, +-- removed on the way out including on error, and never live outside a battle +-- frame this mode is drawing. +local function withoutBackgroundFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, x, y, w, h, ...) + if mode == "fill" and x == 0 and y == 0 + and w == BattleScene.GB_W and h == BattleScene.GB_H then + local r, gr, b, a = g.getColor() + if r > 0.99 and gr > 0.99 and b > 0.99 then + -- Two different full-frame whites, both replaced rather than drawn. + -- + -- OPAQUE is the battle's background, and on the offscreen canvases it + -- doubles as their clear, so there it becomes a transparent one. + -- + -- TRANSLUCENT is the hit flash. Over a white field that reads as a + -- flash; over a world it whites out the map, the HUD and the text box + -- together. BattleScene puts it back on the mons alone. + if a > 0.99 then + local target = g.getCanvas() + if target ~= nil + and (target == battle.bgCanvas or target == battle.waveCanvas) then + g.clear(0, 0, 0, 0) + end + end + return + end + end + return rectangle(mode, x, y, w, h, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + if not ok then error(err, 0) end +end + +-- ------- the box, without its paper +-- +-- Font.drawBox is a white fill and then six border glyphs, and the fill is the +-- opaque slab the frosted panel underneath is there to replace. So for the +-- length of one drawTextArea the white fills are dropped and everything else +-- -- the border, the text, the cursor, the down arrow -- draws exactly as it +-- always did, over the glass instead of over paper. +-- +-- Every fill drawTextArea issues is one of those: the box's own, and the two +-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes +-- the border glyphs that hardware would have overwritten. Both are opaque +-- white, both are paper, and both go. +-- +-- The same shim shape as withoutBackgroundFill above, and scoped as tightly: +-- installed around a single call, removed on the way out including on error, +-- never live outside a battle frame this mode is drawing. +local function withoutBoxFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + if mode == "fill" then + local r, gr, b, a = g.getColor() + if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end + end + return rectangle(mode, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + if not ok then error(err, 0) end +end + +-- ------- the hour's light, on a pic that is not geometry +-- +-- Everything standing in the arena goes through the voxel shader, and that +-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at +-- night it goes blue, and the two mons' cards go with it because they are +-- drawn in the same pass as the ground they stand on. +-- +-- A back pic pinned to the menu is not in that pass. It is the engine's own +-- flat blit over the finished shot, so it arrived at noon while the world +-- behind it was at midnight -- a mon lit by nothing in the frame. +-- +-- So the tint is applied by hand, to that one draw. Every colour the pics +-- layer sets is multiplied on its way past, which is the whole of it: the +-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw +-- colour, so tinting the colour tints the pixels -- and the alpha, the faint +-- slide's fade and the blink's own colour all compose with it rather than +-- being overwritten. +-- +-- What this does NOT get is the sun: the cards are shadow-mapped, so one +-- standing under a tree is darker than the tint alone, and this pic has no +-- position in the scene to be shadowed at. It carries the hour and not the +-- weather, which is the part the eye reads. +local function 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 + -- the layer leaves whatever colour it last set, and that one is tinted; + -- hand the next caller plain white rather than a dimmed one + setColor(1, 1, 1, 1) + if not ok then error(err, 0) end +end + +-- ------- the mons, as textures for the 3D pass +-- +-- The two Pokemon are not composited over the world any more: they are quads +-- standing in it (see BattleBillboard). What that needs from the battle +-- screen is a TEXTURE per side -- and the honest way to get one is to let the +-- engine draw its own pics layer, unchanged, into a canvas. +-- +-- So the layer is rendered twice, once per side, with the other side +-- falsified out of existence by nulling exactly the fields its branches +-- test. Everything the engine does to a pic comes along for free that way: +-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the +-- faint slide, the damage blink, the squish, every SE displacement. None of +-- it is reimplemented and none of it can drift. +-- +-- Two things are forced during that render. The scale, to 1, so the texture +-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the +-- placement, so the pic lands centred on a known column with its feet on a +-- known row. That known point is what the quad is then hung from. +local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline +local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot + +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 +-- (innerAnim, their sibling, is declared up beside animTexture, which +-- sits earlier in the chunk than this group and must see the local) + +local function texCanvasFor(side) + local c = texCanvas[side] + if c then return c end + local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W, + BattleScene.GB_H, { dpiscale = 1 }) + if not ok then return nil end + made:setFilter("nearest", "nearest") + texCanvas[side] = made + return made +end + +-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own +-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or +-- not-yet-sent-out mon simply has no billboard this frame. +local function sideVisible(battle, side) + if side == "enemy" then + if battle.showEnemyTrainer and battle.trainerPic then return true end + return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden + and not battle.enemySendingOut + and not battle:fxHidden(battle.enemy)) and true or false + end + if battle.showPlayerBack and battle.playerBackPic then return true end + local hide = battle.safari or battle.demo + return (battle.player and battle.player.sprite and not hide + and not battle.sendingOut + and not battle:fxHidden(battle.player)) and true or false +end + +local OFF = { + enemy = { player = false, showPlayerBack = false }, + player = { enemy = false, showEnemyTrainer = false }, +} + +-- Render one side's pics layer into its canvas and report where the pic's +-- feet ended up, in canvas coordinates. +function OverworldBattle.sideTexture(battle, side) + if not (innerPics and battle) then return nil end + -- On the STADIUM rung a side standing a MODEL needs no pic: rendering one + -- anyway would hang a second, flat copy of the same Pokemon on the same + -- cell. Asked per side, so a species with no pack -- or a substitute + -- doll, or the trainer before the send-out -- still comes through here. + local okS, covered = pcall(function() + return V.require("Stadium").covers(battle, side) + end) + if okS and covered then return nil end + if not sideVisible(battle, side) then return nil end + local canvas = texCanvasFor(side) + if not canvas then return nil end + + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + -- The pic-window scissors are in the battle screen's fixed coordinates and + -- would clip a pic that has been moved to the middle of its own canvas. + -- There is nothing here for them to protect -- no HUD, no text box, just + -- the one pic -- so they are switched off for the render. + local setScissor, intersectScissor = g.setScissor, g.intersectScissor + local getScissor = g.getScissor + g.setScissor = function() end + g.intersectScissor = function() end + g.getScissor = function() return nil end + + local saved = {} + 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) + if shinyTint then + V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0) + else + innerPics(battle, 0, 0, 0) + end + end) + + texturing = nil + for k in pairs(OFF[side]) do battle[k] = saved[k] end + g.setScissor, g.intersectScissor, g.getScissor = + setScissor, intersectScissor, getScissor + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + g.setBlendMode(prevBlend or "alpha", prevAlpha) + if not ok then error(err, 0) end + + local ax, ay = TEX_AX, TEX_AY + local trainer = false + -- The intro trainer pic draws itself straight into its own 7x7 slot rather + -- than through the placement helpers, so it is hung from that slot instead. + if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then + ax, ay, trainer = TRAINER_AX, TRAINER_AY, true + elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then + trainer = true + end + return { canvas = canvas, ax = ax, ay = ay, trainer = trainer } +end + +-- Whether the hit flash is showing this frame. +-- +-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time +-- decision there (a counter plus the frame parity that makes it flicker) and +-- there is no seam that reports it. Read-only, so the worst a future engine +-- change can do is flash on a frame the engine would not have. +function OverworldBattle.flashing(battle) + local fx = battle and battle.fx + if not (fx and fx.flash and fx.flash > 0) then return false end + return (battle.frame or 0) % 4 < 2 +end + +-- Both sides, or nil when neither has anything to show. +-- +-- One side under BACK SPRITES: the player's mon is not standing on the map at all +-- there, it is on the menu, so it has no card to be a texture for -- and +-- nothing downstream has to know that. No billboard, and no shadow on the +-- ground under a mon that is not on it. +function OverworldBattle.textures(battle) + if not battle then return nil end + local out = {} + local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy") + local okP, player = true, nil + -- a LET'S GO capture session empties the player's side the same way + -- BACK SPRITES does: that mon is simply not in this shot, so no pic is + -- rendered for it and no shadow lands under it + local cap = BattleScene.capture + if not OverworldBattle.backPinned() + and not (cap and cap.hidePlayer) then + okP, player = pcall(OverworldBattle.sideTexture, battle, "player") + end + out.enemy = okE and enemy or nil + out.player = okP and player or nil + -- On the STADIUM rung both sides can legitimately have no pic -- the pair + -- of them are models -- and this table must still come back, because it + -- carries the HIT FLASH, and because the VR eye pass uses its presence to + -- decide there is a staged fight to draw at all. + local okStanding, standing = pcall(function() + return V.require("Stadium").standing() + end) + if not (out.enemy or out.player or (okStanding and standing)) then + return nil + end + out.flash = OverworldBattle.flashing(battle) + return out +end + +-- ------- engine seams +-- +-- Four wraps, each idempotent so a hot reload cannot stack them. + +function OverworldBattle.install() + local OverworldState = require("src.world.OverworldController") + if not OverworldState.dramaticShapeBattleHook then + local inner = OverworldState.pushBattle + -- The one place the overworld starts a battle, and it runs BEFORE the + -- transition is pushed -- which is what lets the cull happen off-screen + -- and the wipe play over a map with nobody on it. + function OverworldState:pushBattle(battle) + pcall(OverworldBattle.begin, self, battle) + return inner(self, battle) + end + OverworldState.dramaticShapeBattleHook = true + end + + -- the STADIUM rung's own four wraps, which drive the models' animations + -- off the fight (see Stadium.install). Idempotent in the same way, and + -- installed whichever rung the row is on: the wraps do nothing at all + -- while no stadium session is live. + pcall(function() V.require("Stadium").install() end) + + local BattleState = require("src.battle.BattleState") + if BattleState.dramaticShapeBattleHook then return end + + -- Integer scales only. The camera is solved to make one overworld square + -- exactly big enough for a pic at its own integer scale (see BattleCam), so + -- the fit never has to come out of the pixels -- and a species override or + -- a battle_sprite_scales entry that asks for 1.7x would undo that and + -- resample the sprite into mush. Rounded rather than refused, so such a mod + -- still gets the bigger or smaller mon it asked for, on the pixel grid. + local innerScale = BattleState.resolveBattleScale + function BattleState.resolveBattleScale(data, side, path, species) + local base = innerScale(data, side, path, species) + -- 1:1 into the billboard texture: the artwork's own pixels, with the + -- quad's world size doing every bit of the scaling. Anything else would + -- resample the sprite twice -- once into the texture and again on the way + -- to the screen -- and a twice-resampled Gen 1 pic is mush. + if texturing then return 1 end + if not OverworldBattle.shot() then return base end + return math.max(1, math.floor((tonumber(base) or 1) + 0.5)) + end + + -- Keyed-out whites inside a pic used to be filled by the white field + -- behind it. There is a world back there now, so they are filled here + -- instead -- see BattlePics, which puts the paper back without touching + -- the silhouette. + -- + -- The pinned pic is told that its feet are on the box, which is what lets + -- the pale-bodied back sprites be filled at all: their bellies leak out + -- through an opening too wide to read as a drain, and only the box under + -- them settles that it is not a hole. Passed the pre-bake image, because + -- that is the one the battle holds a reference to. + local innerPic = BattleState.picImage + function BattleState:picImage(img) + local out = innerPic(self, img) + if not OverworldBattle.shot() then return out end + return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) + end + + -- While a billboard texture is being rendered both pics are put in the same + -- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad + -- has one anchor to hang from whichever side and whichever species it is + -- carrying. Outside that render both helpers answer exactly as they always + -- did. + local innerBack = BattleState.backPlacement + function BattleState.backPlacement(w, h, pad, padL, scale) + local x, y, s = innerBack(w, h, pad, padL, scale) + if not texturing then return x, y, s end + return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s + end + + local innerFront = BattleState.frontPlacement + function BattleState.frontPlacement(ex, ey, w, h, scale) + local x, y, s = innerFront(ex, ey, w, h, scale) + if not texturing then return x, y, s end + return TEX_AX - w * scale / 2, TEX_AY - h * scale, s + end + + local innerDraw = BattleState.draw + function BattleState:draw() + local shot = OverworldBattle.shot() + -- AskName blanks the field on purpose (the nickname prompt is meant to + -- sit on nothing); leave that one alone. + if not shot or self.blankForAskName then + -- nil, not false: the class default is inherited again, so a battle + -- that loses its arena mid-fight goes back to white voids + self.letterboxWhite = nil + self.dramaticShapeShot = nil + return innerDraw(self) + end + self.dramaticShapeShot = shot + -- The world reaches the screen through the seam a render pipeline's + -- finished world image already uses: one window-resolution canvas, + -- blitted a pixel to a pixel, with the 160x144 UI canvas composited over + -- it in the classic letterbox afterwards. That is what makes the backdrop + -- as crisp as the free-roam diorama while the pics and text stay GB art. + local renderer = game().renderer + if renderer and renderer.setWorldOverride then + renderer:setWorldOverride(shot.canvas) + end + -- beginFrame clears the UI canvas white for an opaque state; the world is + -- under it now, so clear it back to nothing and let it through. Safe to + -- do here: an opaque battle is the lowest state drawn, so nothing has + -- drawn into this canvas yet. + love.graphics.clear(0, 0, 0, 0) + -- the white letterbox exists so the window matches the white battle + -- canvas; there is a world out to the window edges now + self.letterboxWhite = false + OverworldBattle.drawHudPanels(self) + withoutBackgroundFill(self, innerDraw) + -- the LET'S GO capture overlay -- the timing ring, the ball readout, + -- the grade splash -- drawn last in the same GB frame the engine's + -- own HUD drew in, so it letterboxes and chunks identically + local cap = BattleScene.capture + if cap and cap.drawGB then pcall(cap.drawGB, self) end + end + + -- The mons are geometry standing on the map now, drawn in the 3D pass + -- before this screen is composited at all, so the flat pics layer has + -- nothing left to do here. Skipped rather than left to draw underneath, or + -- every Pokemon would appear twice: once on its tile and once in its slot. + -- + -- Except under BACK SPRITES, where the player's side never became geometry and this + -- layer is the only thing that draws it. The engine's own onlySide argument + -- does the whole job: one call, the player's branches alone, in the slot and + -- at the scale the GB always put them -- feet on the box, 2x, back view. + innerPics = BattleState.drawPicsLayer + function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) + local shot = self.dramaticShapeShot + if not shot then + return innerPics(self, slide, sx, sy, onlySide, skipMenuClip) + end + -- a capture session shows NO player side at all -- not even the + -- pinned back pic BACK SPRITES would keep on the menu + local cap = BattleScene.capture + if cap and cap.hidePlayer then return end + if OverworldBattle.backPinned() and onlySide ~= "enemy" then + -- under the hour's own light, like everything else in the frame -- see + -- withTint, and the tint BattleScene hands over with the shot. + -- + -- Except on the wavy path, where the pic is baked into the GRAYSCALE bg + -- canvas for the zone pass to colour by region. That pass keys off the + -- red channel, and a night tint pulls red down -- it would not darken + -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a + -- second and the hour survives it fine. + local tint = not self.grayPics and shot.tint or nil + return withTint(tint, innerPics, self, slide, sx, sy, "player", + skipMenuClip) + end + end + + -- The battle's text box and its menus, over the frosted glass laid down for + -- them rather than over their own white paper. The INK is Gen 1's own black + -- and stays that way whatever is behind the glass -- the panel's tint is + -- what earns it its contrast (see BattleHud). + local innerText = BattleState.drawTextArea + function BattleState:drawTextArea() + if not self.dramaticShapeShot then return innerText(self) end + -- While a capture session is being AIMED the box is empty -- the + -- battle's phase is parked, so there is no message in it -- and it + -- covers the bottom third of the frame, which is exactly the room a + -- throw needs to wind up in. So it comes off entirely for those + -- frames and is back the instant a message has something to say. + local cap = BattleScene.capture + if cap and cap.hideTextBox then return end + return withoutBoxFill(self, innerText) + end + + -- Move animations are authored against the pics' fixed slots, and a single + -- animation reaches across both sides, so there is no per-side offset to + -- give them. They ride the average, which is where the pair's centre went + -- -- a few pixels at most, and it keeps a hit landing on the mon it is + -- aimed at instead of drifting off it. + innerAnim = BattleState.drawAnimLayer + function BattleState:drawAnimLayer(colorized) + local shot = self.dramaticShapeShot + if not shot then return innerAnim(self, colorized) end + -- Move animations are authored against the pics' old fixed slots, and one + -- animation reaches across both sides, so there is no per-side offset to + -- give them. They ride to where the PAIR went: the midpoint of the two + -- mons' projected positions, less the midpoint of the slots they used to + -- sit in. A hit still lands on the mon it is aimed at. + -- + -- And they ride the pair's SEPARATION as well, because the mons + -- themselves do. Both are geometry standing on the map, so the camera + -- sizes them: zoom in and they grow, swing round to side-on and the two + -- marks close up as the axis foreshortens. A layer that only slid would + -- have held the authored 106-pixel spacing through all of it -- a beam + -- fired between two mons that are no longer that far apart, ending in + -- the air beside the one it was aimed at. Scaling about the same + -- midpoint keeps every authored offset the same fraction of the gap it + -- was authored as. + local a = OverworldBattle.ANCHOR + -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side + -- contributes no movement at all and the pair's centre has gone half as + -- far as the foe's mark did. + local px, py = shot.player[1], shot.player[2] + if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end + local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 + local ax = (a.enemy[1] + a.player[1]) / 2 + local ay = (a.enemy[2] + a.player[2]) / 2 + love.graphics.push() + love.graphics.translate(cx - ax, cy - ay) + -- Clamped, and skipped outright if the marks ever coincide: a + -- degenerate projection must leave the effects the size they were + -- rather than collapse them to nothing or blow them across the screen. + local k = OverworldBattle.animScale(shot, px, py) + if k ~= 1 then + love.graphics.translate(ax, ay) + love.graphics.scale(k, k) + love.graphics.translate(-ax, -ay) + end + local ok, err = pcall(innerAnim, self, colorized) + love.graphics.pop() + if not ok then error(err, 0) end + end + + -- The engine's flash has a SECOND half, and it is the one that reaches the + -- menu. Beside the white rectangle (dropped above) the flash moves are + -- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the + -- colorized pipeline applies in drawZonePass to the WHOLE background + -- canvas. That canvas carries the HUD glyphs and the text box, so a fade + -- meant for the two mons washed the menu out with them. + -- + -- The fade is left switched on for the pics, which read it through + -- picImage, and switched off for the zone pass alone. So the mons flash + -- and the furniture around them does not. + -- + -- The zone pass has a SECOND thing it paints, and this is the one that + -- reads as the menu box flashing. A screen shake makes it fill every zone + -- with the zone's own color 0 before it draws the offset copy -- the + -- hardware showing empty BG in the strip the shake vacated. On a white + -- battle field that fill is invisible; over a world it is an opaque white + -- sheet across the whole frame, and since a shake program alternates + -- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it + -- switches on and off a few times a second. It is dropped: the background + -- here is the map, so what the shake vacates should show the map. + local innerZone = BattleState.drawZonePass + function BattleState:drawZonePass(src, sx, sy) + if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end + -- shadow the method on the instance for this call only; putting the + -- field back to whatever it was (normally nil) lets the class method be + -- found again + local had = rawget(self, "activeBgp") + self.activeBgp = function() return nil end + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + -- the pass draws no other rectangle; the shake still shifts the copy + if mode == "fill" then return end + return rectangle(mode, ...) + end + local ok, err = pcall(innerZone, self, src, sx, sy) + g.rectangle = rectangle + self.activeBgp = had + if not ok then error(err, 0) end + end + + innerHUDs = BattleState.drawHUDs + function BattleState:drawHUDs(slide) + -- Normally the HUDs have already been drawn this frame, snapped out to the + -- window's edges and composited into the world image (snapHUDs). Drawing + -- them here as well would show each block twice, once in each place. + if self.dramaticShapeShot and snapped() then return end + return innerHUDs(self, slide) + end + + BattleState.dramaticShapeBattleHook = true +end + +-- Whether each HUD block is on screen this frame. +-- +-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam +-- that reports "the enemy HUD is up". A panel under a HUD that is not there +-- would be a frosted slab floating in the arena, so it is worth mirroring; +-- the worst a future engine change can do is show an empty one for a frame, +-- never break a battle. +function OverworldBattle.hudLive(battle, slide) + local enemy = battle.enemy and not battle.showEnemyTrainer + and not battle.enemySendingOut + and not battle:growInScale(battle.enemy) and slide == 0 + and not battle.enemy.fainted + local player = battle.player and not (battle.safari or battle.demo) + and not battle.showPlayerBack and slide == 0 + return enemy and true or false, player and true or false +end + +-- ------- the snapped composite +-- +-- The engine's own HUD layer, rendered into a texture. +-- +-- One thing is falsified for the render, and it is falsified because this layer +-- never reaches the battle's zone pass -- it is composited into the world image, +-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves +-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229); +-- answered false, it tints its own greens and reds instead, exactly as it does +-- on the flat path. +-- +-- Shadowed on the instance for this call only, the way drawZonePass shadows +-- activeBgp: putting the field back to whatever it was (normally nil) lets the +-- class method be found again. +function OverworldBattle.hudTexture(battle, slide) + if not (innerHUDs and battle) then return nil end + local had = rawget(battle, "colorMode") + battle.colorMode = function() return false end + local ok, layer = pcall(BattleHud.layerTexture, + BattleScene.GB_W, BattleScene.GB_H, + function() innerHUDs(battle, slide) end) + battle.colorMode = had + return ok and layer or nil +end + +-- Draw both HUD blocks into the world image at the window's edges, each on its +-- own frosted panel. Returns true when the frame's HUDs are up there and the +-- in-frame draw must be skipped; false leaves the battle screen's own HUD +-- exactly as it was before any of this existed. +-- +-- Both bands are blitted whether or not that side's HUD is LIVE, because a band +-- carries more than the HUD: the pokeball rows of the intro and of an enemy +-- faint, and the safari ball count, all draw in these rows and belong at the +-- same edge as the block they share it with. The panels are the ones that +-- follow hudLive -- frosted glass under nothing is a slab floating in the arena. +function OverworldBattle.snapHUDs(battle, shot) + if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then + return false + end + -- With a headset live the HUDs stay IN the GB frame -- the classic + -- slots, on the glass drawHudPanels lays for the unsnapped path. Both + -- of VR's battle screens (the floating panel and the pokedex's) crop + -- to the letterbox, and a block snapped out to the window's edge would + -- be cropped away with the window around it. + local okV, vr = pcall(V.require, "VR") + if okV and vr and vr.active and vr.active() then return false end + local slide = (battle.introSlide or 0) * 4 + local rects, bandX = OverworldBattle.snapRects(shot) + local enemy, player = OverworldBattle.hudLive(battle, slide) + local live = {} + if not isIOS() then + if enemy then live.enemy = rects.enemy end + if player then live.player = rects.player end + end + -- The text box's frost panel normally goes into this same world-canvas pass. + -- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating + -- the large ghost rectangle behind the Pokemon. Keep the box border/text but + -- skip only this frosted backing on iOS. + if not isIOS() then + for key, rect in pairs(OverworldBattle.textRects(battle)) do + live[key] = toWorld(rect, shot) + end + end + local layer = OverworldBattle.hudTexture(battle, slide) + if not layer then return false end + + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + local ok, err = pcall(function() + g.setCanvas(shot.canvas) + g.setBlendMode("alpha") + for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end + g.setColor(1, 1, 1, 1) + for side, band in pairs(OverworldBattle.HUD_BAND) do + local quad = g.newQuad(band[1], band[2], band[3], band[4], + BattleScene.GB_W, BattleScene.GB_H) + local x = bandX[side] + band[1] * shot.scale + local targetY = shot.ly + band[2] * shot.scale + + if isIOS() then + -- Keep the player's HUD exactly where it currently appears on the + -- right. Only the enemy band needs its mirrored destination corrected. + local y = targetY + if side == "enemy" then + y = shot.ph - targetY - band[4] * shot.scale + end + + -- iOS presents this Canvas-to-Canvas HUD texture upside down. + g.draw(layer, quad, x, y, 0, + shot.scale, -shot.scale, 0, band[4]) + else + g.draw(layer, quad, x, targetY, 0, + shot.scale, shot.scale) + end + end + end) + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + g.setBlendMode(prevBlend or "alpha", prevAlpha) + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end + return true +end + +-- Lay the frosted glass down under whichever HUD and box are about to draw, +-- and record which way the glyphs have to flip. +-- +-- The panels are the fallback path only: normally the HUDs are snapped out to +-- the window's edges and their glass, and the box's, went into the world image +-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is +-- drawn here, in the GB frame, whichever path laid the glass under it. +function OverworldBattle.drawHudPanels(battle) + local shot = battle.dramaticShapeShot + if not shot then return end + if snapped() then + return + end + local slide = (battle.introSlide or 0) * 4 + local enemy, player = OverworldBattle.hudLive(battle, slide) + local rect = OverworldBattle.HUD_RECT + local live = {} + if enemy then live.enemy = rect.enemy end + if player then live.player = rect.player end + for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end + if not next(live) then return end + for _, r in pairs(live) do BattleHud.panel(r, shot) end +end + +return OverworldBattle diff --git a/lib/Pokeball.lua b/lib/Pokeball.lua new file mode 100644 index 0000000..1ce0e83 --- /dev/null +++ b/lib/Pokeball.lua @@ -0,0 +1,594 @@ +-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws. +-- +-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D +-- sprite inside the battle's move-animation layer. This is a ball that can +-- fly through the arena, hang in the air in front of the camera, hinge its +-- lid open, drink a Pokemon in, click shut, rock on the ground and burst +-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like +-- everything else in the diorama, because it is a mesh in Voxel3D's own +-- format going through Voxel3D's own shader. +-- +-- ------- how it is built +-- +-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base +-- and the coloured LID -- each carrying its half of the black band as a +-- slightly bulged latitude belt, so the two halves separate exactly where +-- the real ball separates. The button is a little cylinder standing out of +-- the base's front; the interior is sealed with two pale discs so an open +-- ball shows a shell with a floor rather than a view through to the far +-- wall's backface. Colour is a palette texture one texel per material and +-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the +-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow +-- band without a second mesh, just a different V coordinate. +-- +-- Shade is baked per vertex from the surface normal with StadiumStage's +-- fitted constants, which is this mod's answer for anything curved: the +-- ball's sun side and belly read as a sphere under the same southeastern +-- sun the roofs are lit by. +-- +-- ------- how it animates +-- +-- The HordeGun way: a handful of scalar timers advanced by update(dt) and +-- consumed as matrix terms at draw time. No skeleton, no keyframes -- +-- lid is a hinge matrix about the back of the equator, the wobble is a +-- decaying rotateZ about the ground contact point, the caught click is a +-- squash pulse, the stars are one shared quad drawn a few times facing the +-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop) +-- is the caller's problem, which is what keeps this file a prop and not a +-- game mode. +-- +-- Nothing here touches love.* until something has to be drawn, so the +-- module loads and the state machine runs headless -- the test suite +-- exercises the phases without a GPU. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") + +local Pokeball = {} +Pokeball.__index = Pokeball + +-- ------- the ball's measurements, in world pixels +-- +-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball +-- sits in the hand and against a Pokemon at about the proportion the games +-- draw: unmissable in the foreground, believable at the far cell. +Pokeball.R = 2.2 + +-- the black belt: half-height as a latitude angle, and how far the belt +-- bulges past the shell so it reads as a band and not a painted stripe +local BAND_LAT = 0.16 +local BAND_R = 1.045 + +-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is +-- past upright -- the mouth gapes at the sky, which is the capture pose. +local HINGE_Z = -0.86 -- as a fraction of R +local LID_OPEN = 2.0 + +-- tessellation: enough that the silhouette is round at held-ball size, +-- cheap enough that six of these would not show on a phone's frame budget +local LON = 14 +local LAT = 5 + +-- pose timing +local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second +local BURST_RATE = 14 -- the breakout pop is a violent open +local WOBBLE_T = 0.85 -- one rock, seconds +local WOBBLE_A = 0.38 -- how far it tips, radians +local PULSE_T = 0.14 -- the caught click's squash pulse +local STAR_T = 0.9 -- the caught stars' life +local GLOW_DECAY = 2.2 -- additive glow, per second + +-- ------- palette +-- +-- One texel per material (columns), one row per ball tier. Alpha stays 1 +-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a +-- translucent texel is an invisible one. +local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5, + INNER = 6, GLOW = 7, STAR = 8 } +local SLOT_N = 8 + +local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL", + "SAFARI_BALL" } + +local COLORS = { + POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 }, + bottom = { 0.93, 0.93, 0.95 } }, + GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 }, + bottom = { 0.93, 0.93, 0.95 } }, + ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 }, + bottom = { 0.93, 0.93, 0.95 } }, + MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 }, + bottom = { 0.93, 0.93, 0.95 }, + glow = { 1.0, 0.72, 0.92 } }, + SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 }, + bottom = { 0.90, 0.88, 0.80 } }, +} + +local SHARED = { + ring = { 0.28, 0.28, 0.30 }, + face = { 0.96, 0.96, 0.97 }, + inner = { 0.72, 0.70, 0.68 }, + glow = { 1.00, 0.92, 0.65 }, + star = { 1.00, 0.85, 0.25 }, +} + +local function tierRow(ball) + for i, id in ipairs(TIERS) do + if id == ball then return i end + end + return 1 -- an unknown ball is a plain POKE BALL +end + +-- palette texel centres +local function uvFor(slot, row) + return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS +end + +local palette = nil +local function paletteTexture() + if palette ~= nil then return palette or nil end + local ok, img = pcall(function() + local data = love.image.newImageData(SLOT_N, #TIERS) + for row, id in ipairs(TIERS) do + local c = COLORS[id] + local function put(slot, rgb) + data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1) + end + put(SLOTS.TOP, c.top) + put(SLOTS.BOTTOM, c.bottom) + put(SLOTS.BAND, c.band) + put(SLOTS.RING, SHARED.ring) + put(SLOTS.FACE, SHARED.face) + put(SLOTS.INNER, SHARED.inner) + put(SLOTS.GLOW, c.glow or SHARED.glow) + put(SLOTS.STAR, SHARED.star) + end + local tex = love.graphics.newImage(data) + tex:setFilter("nearest", "nearest") + return tex + end) + palette = ok and img or false + return palette or nil +end + +-- ------- shade +-- +-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern +-- sun, answered for an arbitrary normal instead of one of six faces. +local function shadeFor(nx, ny, nz) + local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11 + return math.max(0.30, math.min(1.00, s)) +end + +-- ------- mesh building +-- +-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices. +-- Quads go through the shared corner order; the discs use a degenerate +-- fourth vertex, which the rasteriser drops as the zero-area triangle it is. +local function quad(verts, map, a, b, c, d) + local n = #verts + verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d + Voxel3D.pushQuad(map, n / 4) +end + +local R = Pokeball.R +local TAU = math.pi * 2 + +-- a latitude zone of the sphere between phi0 and phi1 (radians from the +-- equator, north positive), at radiusK times the shell radius +local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK) + local u, v = uvFor(slot, row) + local r = R * (radiusK or 1) + for i = 0, rows - 1 do + local pa = phi0 + (phi1 - phi0) * (i / rows) + local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows) + for j = 0, LON - 1 do + local ta = TAU * (j / LON) + local tb = TAU * ((j + 1) / LON) + local function corner(phi, th) + local nx = math.cos(phi) * math.sin(th) + local ny = math.sin(phi) + local nz = math.cos(phi) * math.cos(th) + return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) } + end + quad(verts, map, corner(pa, ta), corner(pa, tb), + corner(pb, tb), corner(pb, ta)) + end + end +end + +-- a disc in a y-plane, sealed with fan quads about the centre +local function disc(verts, map, y, radius, slot, row, up) + local u, v = uvFor(slot, row) + local sh = shadeFor(0, up and 1 or -1, 0) + local centre = { 0, y, 0, u, v, sh } + for j = 0, LON - 1 do + local ta = TAU * (j / LON) + local tb = TAU * ((j + 1) / LON) + local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh } + local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh } + quad(verts, map, centre, a, b, centre) + end +end + +-- the button: a ring wall and its face, standing out of the shell along +Z +local function button(verts, map, row) + local BLON = 10 + local function ringWall(rad, z0, z1, slot) + local u, v = uvFor(slot, row) + for j = 0, BLON - 1 do + local ta = TAU * (j / BLON) + local tb = TAU * ((j + 1) / BLON) + local function at(th, z) + local nx, ny = math.cos(th), math.sin(th) + return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) } + end + quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1)) + end + end + local function faceDisc(rad, z, slot) + local u, v = uvFor(slot, row) + local sh = shadeFor(0, 0, 1) + local centre = { 0, 0, z, u, v, sh } + for j = 0, BLON - 1 do + local ta = TAU * (j / BLON) + local tb = TAU * ((j + 1) / BLON) + local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh } + local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh } + quad(verts, map, centre, a, b, centre) + end + end + -- the wall starts inside the shell so the junction never shows a gap + ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING) + faceDisc(0.75, R + 0.30, SLOTS.RING) + ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING) + faceDisc(0.45, R + 0.42, SLOTS.FACE) +end + +-- one tier's meshes, memoised: { base = , lid = , spark = } +-- +-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel; +-- the glow disc, the beam and every star are that card under a matrix. +local meshes = {} +local function meshesFor(ball) + local row = tierRow(ball) + local hit = meshes[row] + if hit ~= nil then return hit or nil end + + local ok, built = pcall(function() + local bv, bm = {}, {} + -- the base: white bowl from the south pole up to the band, its half of + -- the band, the interior floor and the button on the front + zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row) + zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R) + disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true) + button(bv, bm, row) + + local lv, lm = {}, {} + -- the lid: its half of the band up to the coloured dome, and its pale + -- underside, which is what shows once the hinge tips it back + zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R) + zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row) + disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false) + + local base = Voxel3D.newMesh(bv, bm) + local lid = Voxel3D.newMesh(lv, lm) + if not (base and lid) then return nil end + + local function card(slot) + local u, v = uvFor(slot, row) + local cv, cm = {}, {} + quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 }, + { 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 }) + return Voxel3D.newMesh(cv, cm) + end + return { base = base, lid = lid, + glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) } + end) + meshes[row] = (ok and built) or false + return meshes[row] or nil +end + +-- dropped so a lost GL context (Android resume) rebuilds everything +function Pokeball.invalidate() + meshes = {} + palette = nil +end + +-- ------- an instance: one ball with a pose +-- +-- Loads and runs without graphics; only draw() and cast() want a GPU. +function Pokeball.new(ball) + return setmetatable({ + ball = ball or "POKE_BALL", + pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE + yaw = 0, -- which way the button faces + scale = 1, + spin = 0, -- visual spin about the vertical, rad/s + tumble = 0, -- end-over-end in flight, rad/s + roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about + -- the axis out of the ball's face, which + -- with the yaw at the camera reads as the + -- ball turning clockwise/counter-clockwise + -- to the viewer -- the curveball wind-up + spinAngle = 0, tumbleAngle = 0, rollAngle = 0, + lid = 0, lidTarget = 0, lidRate = LID_RATE, + wobbleT = nil, wobbleDir = 1, + pulse = nil, -- the caught click's squash + glow = 0, + stars = nil, -- caught celebration, or nil + visible = true, + }, Pokeball) +end + +-- ------- the verbs the capture flow speaks + +function Pokeball:open() + self.lidTarget, self.lidRate = 1, LID_RATE + self.glow = 1 +end + +function Pokeball:close() + self.lidTarget, self.lidRate = 0, LID_RATE +end + +-- one rock on the ground; dir alternates shakes. Returns how long it takes, +-- so the caller can sequence the pauses between shakes. +function Pokeball:rock(dir) + self.wobbleT = 0 + self.wobbleDir = dir or 1 + return WOBBLE_T +end + +-- the caught click: squash pulse, a soft flash, and the stars +function Pokeball:catchClick() + self.pulse = 0 + self.glow = 0.6 + local stars = {} + for i = 1, 6 do + stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 } + end + self.stars = stars +end + +-- the breakout: the lid blown open and a hard flash +function Pokeball:burst() + self.lidTarget, self.lidRate = 1, BURST_RATE + self.glow = 1 +end + +function Pokeball:busy() + return self.wobbleT ~= nil or self.pulse ~= nil + or math.abs(self.lid - self.lidTarget) > 0.02 +end + +function Pokeball:update(dt) + -- lid toward its target, at whatever violence was asked for + local d = self.lidTarget - self.lid + if d ~= 0 then + local step = self.lidRate * dt + if math.abs(d) <= step then + -- arriving CLOSED from open is the shut click: the squash pulse + if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end + self.lid = self.lidTarget + else + self.lid = self.lid + (d > 0 and step or -step) + end + end + if self.wobbleT then + self.wobbleT = self.wobbleT + dt + if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end + end + if self.pulse then + self.pulse = self.pulse + dt + if self.pulse >= PULSE_T then self.pulse = nil end + end + if self.stars then + local live = false + for _, s in ipairs(self.stars) do + s.t = s.t + dt + if s.t < STAR_T then live = true end + end + if not live then self.stars = nil end + end + self.glow = math.max(0, self.glow - GLOW_DECAY * dt) + self.spinAngle = self.spinAngle + self.spin * dt + self.tumbleAngle = self.tumbleAngle + self.tumble * dt + self.rollAngle = self.rollAngle + self.roll * dt +end + +-- ------- pose as a matrix + +local function smooth(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + return t * t * (3 - 2 * t) +end + +function Pokeball:matrix() + local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]), + Mat4.rotateY(self.yaw)) + if self.wobbleT then + -- a decaying rock about the ground contact: tip, cross through centre, + -- tip the other way, settle + local t = self.wobbleT / WOBBLE_T + local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir + m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0), + Mat4.mul(Mat4.rotateZ(a), + Mat4.translate(0, R * self.scale, 0)))) + end + if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end + if self.tumbleAngle ~= 0 then + m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle)) + end + -- the roll turns about the ball's own face axis, so with the yaw aimed + -- at the camera it reads as clockwise/counter-clockwise on screen + if self.rollAngle ~= 0 then + m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle)) + end + local k = self.scale + if self.pulse then + -- the click: a quick squash and back, more felt than seen + local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14 + m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p))) + elseif k ~= 1 then + m = Mat4.mul(m, Mat4.scale(k, k, k)) + end + return m +end + +-- the hinge: the lid's own extra transform about the back of the equator +local function lidMatrix(open) + if open <= 0 then return nil end + local a = -LID_OPEN * smooth(open) + local hz = HINGE_Z * R + return Mat4.mul(Mat4.translate(0, 0, hz), + Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz))) +end + +-- where the open mouth is, for aiming the capture beam +function Pokeball:mouth() + return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3] +end + +-- ------- drawing +-- +-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly +-- like Stadium.draw. Seams and glass are off for the duration: the ball is +-- not on the voxel grid and does not wear the tileset atlas. +local function eyeYaw(x, z) + local eye = Voxel3D.eye + if not eye then return 0 end + return math.atan2(eye[1] - x, eye[3] - z) +end + +function Pokeball:draw(pull) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + Voxel3D.seams(false) + Voxel3D.glass(false) + local model = self:matrix() + Voxel3D.draw(m.base, pal, model, pull) + local lidM = lidMatrix(self.lid) + Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull) + + -- the additive dressing: the open-mouth glow and the caught stars. + -- Depth writes are off under "add" (Voxel3D.blend), so these can never + -- punch holes for later draws. + local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars + if anythingAdd then + Voxel3D.blend("add") + if self.glow > 0.05 and self.lid > 0.1 then + -- a pulsing octahedron of light standing in the mouth: two crossed + -- cards read from every seat in the house + local gx, gy, gz = self:mouth() + local s = R * (1.1 + 0.25 * self.glow) * self.scale + for i = 0, 1 do + local card = Mat4.mul(Mat4.translate(gx, gy, gz), + Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2), + Mat4.scale(s, s, 1))) + Voxel3D.draw(m.glow, pal, card, pull) + end + end + if self.stars then + for _, s in ipairs(self.stars) do + if s.t > 0 and s.t < STAR_T then + local t = s.t / STAR_T + local rr = (R + 4.5 * t) * self.scale + local sx = self.pos[1] + math.sin(s.th) * rr + local sz = self.pos[3] + math.cos(s.th) * rr + local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale + local sc = 1.1 * (1 - t) + local card = Mat4.mul(Mat4.translate(sx, sy, sz), + Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)), + Mat4.mul(Mat4.rotateZ(TAU * t * 0.5), + Mat4.scale(sc, sc, 1)))) + Voxel3D.draw(m.star, pal, card, pull) + end + end + end + Voxel3D.blend(nil) + end + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- the capture beam: a crossed pair of additive cards stretched from the +-- ball's mouth to the mon it is drinking in. Separate from draw() because +-- the caller owns the far end and the fade. +function Pokeball:drawBeam(tx, ty, tz, width, strength, pull) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + local x, y, z = self:mouth() + local dx, dy, dz = tx - x, ty - y, tz - z + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 0.5 then return end + dx, dy, dz = dx / len, dy / len, dz / len + -- two perpendiculars to the beam axis + local ux, uy, uz + if math.abs(dy) < 0.94 then + ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised + local l = math.sqrt(ux * ux + uz * uz) + ux, uz = ux / l, uz / l + else + ux, uy, uz = 1, 0, 0 + end + local vx = dy * uz - dz * uy + local vy = dz * ux - dx * uz + local vz = dx * uy - dy * ux + local w = (width or R) * (strength or 1) + Voxel3D.seams(false) + Voxel3D.glass(false) + Voxel3D.blend("add") + -- the unit card is x -0.5..0.5, y 0..1: columns map its x to a + -- perpendicular and its y to the full run of the axis + local a = { ux * w, dx * len, vx, x, + uy * w, dy * len, vy, y, + uz * w, dz * len, vz, z, + 0, 0, 0, 1 } + local b = { vx * w, dx * len, ux, x, + vy * w, dy * len, uy, y, + vz * w, dz * len, uz, z, + 0, 0, 0, 1 } + Voxel3D.draw(m.glow, pal, a, pull) + Voxel3D.draw(m.glow, pal, b, pull) + Voxel3D.blend(nil) + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- ------- the sun's view +-- +-- The same two shells under the same matrix, so the shadow on the ground is +-- the pose the camera sees. The caller folds a term into the shadow +-- signature while a ball is live (the sun pass is cached -- see +-- BattleScene.shadowSignature) or this freezes on its first frame. +function Pokeball:cast(shadowMap) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + local model = self:matrix() + shadowMap.draw(m.base, pal, model) + local lidM = lidMatrix(self.lid) + shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model) +end + +-- a term for the arena's cached shadow signature: quantised, so the cache +-- only re-renders when the ball has visibly moved +function Pokeball:signature() + if not self.visible then return "" end + return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4), + math.floor(self.pos[3] * 4), math.floor(self.lid * 8), + self.wobbleT and math.floor(self.wobbleT * 30) or -1 }, + ",") +end + +return Pokeball 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 aceeab9..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,16 +392,55 @@ 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 + -- same way its card and back pic are (the shrink half of the story is + -- below, AFTER the grow block, which reassigns mon.scale every frame) + local cap = V.require("BattleScene").capture + if side == "player" and cap and cap.hidePlayer then + mon.visible = false + end -- cleared up front, so a side that has just lost its rig cannot leave -- last frame's matrix behind it mon.model_matrix = nil @@ -425,6 +475,16 @@ function Stadium.update(dt, battle, groundY) local okG, grow = pcall(battle.growInScale, battle, battler) mon.scale = (okG and grow) or 1 end + -- LET'S GO capture: the foe drinking into the ball. AFTER the grow + -- block on purpose -- that block reassigns mon.scale every frame, + -- and the first cut of this hook sat above it and was silently + -- clobbered: the model stood at full size over a ball that had + -- supposedly swallowed it. The session's fraction owns the scale + -- for as long as it exists; the frame it clears, the grow block + -- above is already putting the engine's own answer back. + if side == "enemy" and cap and cap.shrink then + mon.scale = cap.shrink + end mon:update(dt or 0) if mon.visible and arena then local cell = arena[side] @@ -491,6 +551,34 @@ function Stadium.guard(side, mon, what, fn) return false end +-- The foe's body for the capture mode's collision and ring, when a MODEL +-- is standing there instead of a pic: its own measured height and +-- footprint, in world pixels. A model stands on the ground, so the body's +-- centre is half its height up. nil whenever no model covers the foe, +-- which sends CatchThrow to its pic measurement instead. +function Stadium.captureBody() + if not session then return nil end + local mon = session.enemy + if not (mon and mon.rig and mon.visible) then return nil end + local okH, h = pcall(mon.worldHeight, mon) + if not (okH and h and h > 0) then return nil end + -- The POSED body, when there is one: a flying Pokemon is nowhere near + -- the mark its cell projects to, and only the pose knows where it went + -- (StadiumMon:bodySpan). The bind-pose figures stand in until the + -- first skin, which is right for everything that keeps its feet down. + local okS, centre, half, girth = pcall(mon.bodySpan, mon) + if okS and centre then + return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))), + yOff = centre, + hh = math.max(4, half) } + end + local okR, r = pcall(mon.worldRadius, mon) + local rr = (okR and r and r > 0) and r or h * 0.4 + return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))), + yOff = h * 0.5, + hh = math.max(4, h * 0.55) } +end + function Stadium.draw(pull) if not session then return end for _, side in ipairs({ "enemy", "player" }) do 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 c148ac2..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 @@ -456,6 +475,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ) Mat4.translate(0, -lift, 0)) end +-- How far this Pokemon's LOWEST rendered point stands above the ground it +-- is placed on, in world pixels -- the authored hover the matrix above +-- gives back, actually applied. +-- +-- Derived by repeating that matrix's own arithmetic rather than by +-- re-deriving it in closed form: root scale, the model's floor and the +-- hover cap interact in a way that is easy to get subtly wrong, and a +-- caller that guessed would place things at the feet of a Pokemon that is +-- flying. Which is exactly what a Pidgey does -- it renders a good third +-- of its own height clear of its tile, and anything aimed at its cell +-- mark lands under it. +function StadiumMon:groundGap() + local centre, half = self:bodySpan() + if centre then return math.max(0, centre - half) end + return 0 +end + +-- Where this Pokemon's body actually SITS above the ground it is placed +-- on, and how big it is: the centre height, the half height and the +-- girth, all in world pixels. +-- +-- Measured off the POSED vertices (StadiumRig:posedBounds) and put +-- through this matrix's own scale and lift, so the answer is the shape +-- the camera is about to see. That matters most for the species it is +-- hardest to guess about: a Pidgey's standby animation flies it well +-- clear of its tile, and anything aimed at its cell mark -- a capture +-- ring, a thrown ball's collision -- lands under an empty patch of grass +-- while the bird hovers above it. Nothing static says so; only the pose +-- does. +-- +-- nil before the first skin(), or with no rig: the caller falls back to +-- the bind-pose height, which is right for everything that stands. +function StadiumMon:bodySpan() + local model, rig = self.model, self.rig + if not (model and rig and rig.posedBounds) then return nil end + local okB, lo, hi, girth = pcall(rig.posedBounds, rig) + if not (okB and lo) then return nil end + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local k = root * self:worldHeight() / math.max(model.height, 1e-6) + k = k * (self.scale or 1) + local floor = model.floor or 0 + local hover = math.min(math.max(floor, 0), + StadiumMon.HOVER_CAP * math.max(model.height, 0)) + local lift = (floor - hover) / root + -- the same map the model matrix applies: world = k * (posed - lift) + return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0) +end + -- Pose and skin for this frame. Separate from the draw because both the -- SUN and the camera -- and, in a headset, both eyes -- want the same -- skinned mesh, and skinning it once is the whole reason this is worth 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/StadiumRig.lua b/lib/StadiumRig.lua index bfc79f6..e40d052 100644 --- a/lib/StadiumRig.lua +++ b/lib/StadiumRig.lua @@ -728,6 +728,36 @@ function StadiumRig:skin(yaw) end end +-- What this POSE actually occupies, in the rig's own posed space: the +-- vertical span of every skinned vertex, and the furthest any of them +-- stands from the model's vertical axis. +-- +-- Read off the skinned rows rather than off the pack's bind-pose figures, +-- because the two are not the same claim. The bind measurements say how +-- big the model is; a caller placing something ON the Pokemon needs to +-- know where the Pokemon IS, and for a flying species the standby +-- animation carries it a third of its own height off the floor -- a lift +-- that exists only in the posed bones and appears in no static field. +-- +-- Answers nil before the first skin(), which is the caller's cue to fall +-- back to the bind figures. +function StadiumRig:posedBounds() + local lo, hi, r2 = nil, nil, 0 + for _, part in ipairs(self.parts) do + local rows, n = part.rows, part.prim.vertCount + for k = 1, n do + local row = rows[k] + local y = row[2] + if not lo or y < lo then lo = y end + if not hi or y > hi then hi = y end + local d = row[1] * row[1] + row[3] * row[3] + if d > r2 then r2 = d end + end + end + if not lo then return nil end + return lo, hi, math.sqrt(r2) +end + -- ------- which texture each part wears this frame -- -- The eyes. A primitive whose display list carried geo command 0x23 with a diff --git a/lib/Structures.lua b/lib/Structures.lua index 725dbac..851de65 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3508,13 +3508,49 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data) templates[tileId] = tpl end local wx, wz = tx * 8, ty * 8 + -- Stable diagonal phase per tuft. Both ends of every quad receive + -- the same value, so a gust bends the slab without shearing it. + local sway = wx * 0.050 + wz * 0.031 for _, q in ipairs(tpl) do quads[#quads + 1] = { { q[1][1] + wx, q[1][2], q[1][3] + wz }, { q[2][1] + wx, q[2][2], q[2][3] + wz }, { q[3][1] + wx, q[3][2], q[3][3] + wz }, { q[4][1] + wx, q[4][2], q[4][3] + wz }, - uv = q.uv, shade = q.shade, + uv = q.uv, shade = q.shade, sway = sway, + cx = wx + 4, cz = wz + 4, + } + end + + -- Sparse wind-borne leaf. It reuses one opaque grass texel and is + -- animated entirely on the GPU, so no per-frame Lua particles exist. + if #tpl > 0 and ((tx * 13 + ty * 7) % 11 == 0) then + local src = tpl[1] + local uv = src.uv and src.uv[1] or { src.u, src.v } + local lx = wx + 2 + ((tx * 5 + ty * 3) % 5) + local lz = wz + 4 + local ly, size = 9 + ((tx + ty) % 3), 1.25 + quads[#quads + 1] = { + { lx - size, ly, lz }, { lx + size, ly, lz }, + { lx + size, ly + size, lz }, { lx - size, ly + size, lz }, + uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 0.73, + cx = lx, cz = lz, leaf = true, + } + end + + -- Rarer one-pixel firefly. Geometry exists all day, but the shader + -- gives it zero glow outside outdoor night. + if #tpl > 0 and ((tx * 17 + ty * 11) % 29 == 0) then + local src = tpl[1] + local uv = src.uv and src.uv[1] or { src.u, src.v } + local fx = wx + 2 + ((tx * 3 + ty * 5) % 5) + local fz = wz + 4 + local fy, half = 9 + ((tx + ty) % 4), 0.5 + quads[#quads + 1] = { + { fx - half, fy, fz }, { fx + half, fy, fz }, + { fx + half, fy + 1, fz }, { fx - half, fy + 1, fz }, + uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 1.37, + cx = fx, cz = fz, firefly = true, } end 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 b3ddb13..5b3d7f9 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -48,6 +48,21 @@ Voxel3D.FORMAT = { { "VertexShade", "float", 1 }, } +-- Tall grass carries one extra value: a stable phase shared by every +-- vertex in a tuft. Keeping it constant prevents the two ends of a blade +-- from shearing apart while the gust travels across the map. +Voxel3D.GRASS_FORMAT = { + { "VertexPosition", "float", 3 }, + { "VertexTexCoord", "float", 2 }, + { "VertexShade", "float", 1 }, + { "VertexGrass", "float", 4 }, -- phase, clump centre x/z, effect kind +} + +Voxel3D.GRASS_WIND_PIXELS = 1.15 +Voxel3D.GRASS_WIND_SPEED = 2.35 +Voxel3D.GRASS_INTERACT_RADIUS = 12 +Voxel3D.GRASS_INTERACT_PIXELS = 2.5 + -- Face shading by direction id: top faces stay -- full brightness, sides step down so an extruded block reads as solid -- instead of a flat sticker, and the faces turned away from the sun are @@ -73,6 +88,8 @@ local SHADER = [[ varying float vShade; varying vec3 vSun; // this fragment's place in the sun's view varying float vFog; // how deep into the map's haze it stands + varying float vFirefly; // zero normally, night glow on firefly cards + uniform float fireflyNight; // shared safely by vertex and pixel stages #ifdef VOXEL_CULL // where this fragment stands in the FLAT world, for the diorama's // viewport to measure. Same precision reasoning as vGrid below: a @@ -96,9 +113,14 @@ local SHADER = [[ uniform float pull; uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off uniform vec4 fogInfo; // density, start, heightK; density 0 = clear + uniform vec4 grassWind; // enabled, time, wind pixels, speed + uniform vec4 grassPlayer; // world x, world z, radius, push pixels + uniform vec2 grassPrevious; // previous player world xz for swept contact attribute float VertexShade; + attribute vec4 VertexGrass; vec4 position(mat4 transform_projection, vec4 vertex_position) { vShade = VertexShade; + vFirefly = 0.0; #ifdef VOXEL_GRID // MODEL space, deliberately: every mesh here is built a unit per // voxel in its own frame, so the seams ride the model however it is @@ -117,6 +139,60 @@ local SHADER = [[ // answered. (The pull below is excluded for the same reason: it is a // depth trick aimed at the camera's own buffer.) vSun = (sunVP * (sunModel * vertex_position)).xyz; + + // Wind and player contact are applied in world space, before the curved + // world and camera pull. vertex y is 0..8 for these tuft meshes, which + // pins the root and lets the tip receive the full displacement. + if (grassWind.x > 0.5) { + float bend = clamp(vertex_position.y / 8.0, 0.0, 1.0); + bend *= bend; + float wave = sin(grassWind.y * grassWind.w + VertexGrass.x); + if (VertexGrass.w > 1.5) { + // One-pixel firefly with a full behaviour loop: a long rest on the + // grass, smooth take-off, an irregular short flight, descent, landing + // and another pause. The baked phase keeps every insect independent. + float cycle = fract(grassWind.y * 0.052 + VertexGrass.x * 0.173); + float takeoff = smoothstep(0.30, 0.39, cycle); + float landing = 1.0 - smoothstep(0.68, 0.79, cycle); + float airborne = takeoff * landing; + float drift = grassWind.y * 0.83 + VertexGrass.x * 3.7; + float wander = sin(drift) * 2.8 + sin(drift * 0.37 + 1.3) * 1.5; + float lift = 2.5 + sin(drift * 1.19) * 1.1 + + sin(drift * 0.53 + 0.8) * 0.7; + w.x += airborne * wander; + w.y += airborne * lift; + // Mostly dim while resting, visibly brighter in flight, with a soft + // asynchronous pulse rather than a hard on/off blink. + float blink = 0.68 + 0.32 * (sin(drift * 2.11) * 0.5 + 0.5); + vFirefly = fireflyNight * blink * mix(0.14, 0.92, airborne); + } else if (VertexGrass.w > 0.5) { + // The first 72% of the cycle is airborne. A leaf gets an initial + // upward lift, travels with the wind, then gravity accelerates it + // down to the ground. It rests there for the remainder before a new + // leaf is emitted. Z remains fixed, preserving sprite depth order. + float life = fract(grassWind.y * 0.085 + VertexGrass.x * 0.159); + float fall = min(life / 0.72, 1.0); + float travel = fall * 44.0 - 6.0; + float flutter = grassWind.y * 3.0 + VertexGrass.x * 4.7; + float lift = sin(fall * 3.14159265) * 5.0; + float gravity = 9.0 * fall * fall; + float flutterFade = 1.0 - smoothstep(0.62, 1.0, fall); + w.x += travel + sin(flutter) * 1.2 * flutterFade; + w.y += lift - gravity + + sin(flutter * 0.61) * 0.8 * flutterFade; + } else { + vec2 offset = vec2(wave * grassWind.z, 0.0); + vec2 clump = VertexGrass.yz; + float bodyDist = length(clump - grassPlayer.xy); + float touch = 1.0 - smoothstep(grassPlayer.z * 0.45, + grassPlayer.z, bodyDist); + float side = clump.x < grassPlayer.x ? -1.0 : 1.0; + if (abs(clump.x - grassPlayer.x) < 0.5) + side = sin(VertexGrass.x) < 0.0 ? -1.0 : 1.0; + w.xz += offset * bend; + w.x += side * touch * grassPlayer.w; + } + } // THE MAP'S HAZE (see ForestAtmos): how much fog stands between the // eye and this vertex -- distance dissolves into it, altitude climbs // out of it. Worked out on the FLAT world like the shadow lookup @@ -160,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; } @@ -353,6 +442,9 @@ local SHADER = [[ // solid silhouette. Last in the chain, so neither the sun nor a voxel // seam can mottle it. rgb = mix(rgb, ghostColor, ghost); + // Emissive but still coloured: increasingly visible as Lua raises the + // night factor, without adding more insects or washing the scene white. + rgb = mix(rgb, vec3(0.82, 1.00, 0.22), vFirefly); // the viewport's rim is an ALPHA, so the last of the model blends into // whatever the frame opened with -- the sky, or the chroma key. 1 // everywhere without the cut compiled in, which is every flat frame. @@ -505,6 +597,15 @@ function Voxel3D.newMesh(verts, map) return mesh end +function Voxel3D.newGrassMesh(verts, map) + if #verts == 0 then return nil end + local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.GRASS_FORMAT, verts, + "triangles", "static") + if not ok then return nil end + if map and #map > 0 then pcall(mesh.setVertexMap, mesh, map) end + return mesh +end + -- The quad corner offsets and UV corners for one face direction, in the -- order the vertex map below stitches into two triangles. Corners are unit -- offsets from the voxel's (x, y, z) minimum corner. @@ -1071,6 +1172,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR) -- the hour's light, as the caller last set it (see Voxel3D.tint) pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 }) + pcall(sh.send, sh, "fireflyNight", Voxel3D.fireflyNight or 0) -- and the map's haze (see Voxel3D.fog), density 0 when there is none local fog = Voxel3D.fog pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 }) @@ -1101,6 +1203,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0) -- on until a sprite pass says otherwise, reset per frame like `ghost` pcall(sh.send, sh, "glassOn", 1) + -- still until the dedicated grass pass enables it + pcall(sh.send, sh, "grassWind", { 0, 0, 0, 0 }) + pcall(sh.send, sh, "grassPlayer", { -100000, -100000, 1, 0 }) + pcall(sh.send, sh, "grassPrevious", { -100000, -100000 }) -- the curved world bends about the camera's focus, so the horizon keeps -- a fixed distance ahead of the player rather than sitting on the map. -- A placed camera may decline it outright (Voxel3D.camera.curve = 0). @@ -1380,6 +1486,24 @@ function Voxel3D.glass(on) pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) end +-- Enable wind only around tall-grass draws. px/pz are the current player's +-- feet in world pixels; previous values make collision continuous per frame. +function Voxel3D.grassWind(on, px, pz, previousX, previousZ) + if not (active and activeShader) then return end + if not on then + pcall(activeShader.send, activeShader, "grassWind", { 0, 0, 0, 0 }) + return + end + local now = love.timer and love.timer.getTime and love.timer.getTime() or 0 + pcall(activeShader.send, activeShader, "grassWind", + { 1, now, Voxel3D.GRASS_WIND_PIXELS, Voxel3D.GRASS_WIND_SPEED }) + pcall(activeShader.send, activeShader, "grassPlayer", + { px or -100000, pz or -100000, + Voxel3D.GRASS_INTERACT_RADIUS, Voxel3D.GRASS_INTERACT_PIXELS }) + pcall(activeShader.send, activeShader, "grassPrevious", + { previousX or px or -100000, previousZ or pz or -100000 }) +end + function Voxel3D.endGhost() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 46c9f7e..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") @@ -934,6 +935,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local GlassMask = V.require("GlassMask") Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 + -- The existing day/night ramp is also a darkness factor. Fireflies fade + -- in naturally at dusk and reach full contrast only at deepest night. + Voxel3D.fireflyNight = outdoor and DayNight.windowLight() or 0 local g = VoxelScene.glintStep(glint, cx, cy) Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp -- and the map's atmosphere, if it has one (see ForestAtmos): the haze @@ -1047,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, @@ -1186,6 +1194,21 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- so the tuft rows keep exactly the characters' own depth handicap local lean = math.max(leanAngle(), 0.05) local pull = VoxelScene.pull(lean) + -- Character px/py is anchored on the 16 px card. Its world centre/feet + -- contact used by the camera code is +8,+8, so use the same point here. + -- Keep the prior rendered point so fast steps sweep through every tuft. + local gx, gz = -100000, -100000 + if me then gx, gz = me.px + 8, me.py + 8 end + VoxelScene._grassPrevX = VoxelScene._grassPrevX or gx + VoxelScene._grassPrevZ = VoxelScene._grassPrevZ or gz + -- A warp/map transition is not a walk. Do not sweep one enormous contact + -- segment across the new map when the player jumps more than two tiles. + local gdx, gdz = gx - VoxelScene._grassPrevX, gz - VoxelScene._grassPrevZ + if gdx * gdx + gdz * gdz > 32 * 32 then + VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz + end + Voxel3D.grassWind(true, gx, gz, + VoxelScene._grassPrevX, VoxelScene._grassPrevZ) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do if ViewBox.showsMap(nb) then @@ -1193,6 +1216,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Mat4.translate(nb.ox, 0, nb.oy), pull) end end + Voxel3D.grassWind(false) + VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz -- flower billboards: pulled like the characters and the grass, MINUS -- the depth of 8 world pixels along the view (8 sin a -- the camera -- looks along (0, -cos a, -sin a), so that is exactly one tile row of diff --git a/main.lua b/main.lua index 2501e96..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. @@ -104,6 +111,11 @@ local Horde = V.require("Horde") local HordeGun = V.require("HordeGun") local HordeHud = V.require("HordeHud") local HordeSfx = V.require("HordeSfx") +-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the +-- wraps and the experience math; CatchThrow the session (input, arc, +-- ring, choreography); Pokeball the animated prop they throw. +local LetsGo = V.require("LetsGo") +local Pokeball = V.require("Pokeball") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -196,6 +208,19 @@ mod.content.render_pipelines:register("voxel", { -- the atmosphere's own clock (shaft shimmer, drifting motes), on the -- same tick so the beams keep breathing through a dialog box ForestAtmos.update(dt) + -- LET'S GO rides the same always-running tick, and BEFORE the battle's + -- own update on purpose: the capture session poses the Poke Ball here, + -- and OverworldBattle.update renders the arena a moment later -- so + -- the ball each frame draws is the ball that frame computed. Guarded, + -- and loudly: a fault in the capture game must cost the capture game, + -- not the whole voxel pipeline. + do + local okLG, errLG = pcall(LetsGo.update, dt) + if not okLG and not V.letsGoWarned then + V.letsGoWarned = true + mod.log:warn("LET'S GO update failed: %s", tostring(errLG)) + end + end -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle -- screen the engine composites, which is not a stage the registry has. @@ -309,6 +334,7 @@ mod.content.render_pipelines:register("voxel", { ChunkMesher.invalidate() -- no map id = every cached mesh ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels VR.invalidate() -- the mirror, and FBO ids of dead canvases + Pokeball.invalidate() -- the ball's meshes and palette texture end, }) @@ -429,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 @@ -477,66 +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 }, - { 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." }, + -- `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 -- 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 @@ -545,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 @@ -732,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 @@ -824,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) @@ -850,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 @@ -900,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 @@ -1007,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: @@ -1044,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 @@ -1056,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. @@ -1136,6 +1280,15 @@ end -- become the same eight buttons. See lib/Horde.lua. Horde.install() +-- ------- LET'S GO capture mode +-- +-- After every other input seam on purpose: while a throw is being aimed +-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is +-- read before anything else can claim the pointer -- and outside the aim +-- they forward every byte untouched. The battle-side wraps (throwBall, +-- safariAction) and the experience hooks install here too. +LetsGo.install() + -- ------- edge-anchored menus stay in the GB frame while a headset is live -- -- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START diff --git a/manifest.json b/manifest.json index 299eb32..f0a48fb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.7.2", + "version": "1.8.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index f8c9edb..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, 12, "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[9] --- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6] and AA --- hookedRows[10]; all three 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,6 +916,9 @@ 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["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 local settingGame = { save = { options = {} }, mods = { modOptions = {} } } @@ -524,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[10] +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 " @@ -564,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 @@ -3285,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") @@ -5911,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 @@ -6184,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, @@ -6193,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") @@ -6275,6 +6837,176 @@ VB.setting:sync(boxWas) VS.angle = angleWas end)() +-- ------- LET'S GO: the capture mode's arithmetic and the ball's pose +-- +-- The throw itself is a rendered, pointer-driven thing the suite cannot +-- fly, but everything consequential under it is plain arithmetic and runs +-- headless: the scaled experience formula (checked against the published +-- Let's Go table), the combo ladder, and the Pokeball prop's state +-- machine, which must load and animate without a GPU. +;(function() + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local LetsGo = lib.require("LetsGo") + local Pokeball = lib.require("Pokeball") + local Mat4 = lib.require("Mat4") + + -- the reference points from the community-measured table: a Lv30 + -- Chansey (base exp 395) pays 2371 to a Lv30 receiver and 5497 to a + -- Lv10 one, before any catch bonuses + T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30, + mon = { level = 30 } }, 1), 2371, + "the scaled formula at equal levels is b*L/5 + 1 -- the table's 2371") + T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30, + mon = { level = 10 } }, 1), 5497, + "and a Lv10 receiver pulls the table's 5497 from the same catch -- " + .. "the receiver's own level is in the denominator") + T.check(LetsGo._scaledGain({ defeatedDef = { baseExp = 40 }, level = 3, + 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) + local wildKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 30 } }, 1) + local trainerKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 30 }, + isTrainer = true }, 1) + T.eq(wildKO, 2371, "a wild payout is unchanged by the trainer term") + T.eq(trainerKO, 3556, "a trainer's Pokemon pays the same formula at 1.5") + T.check(trainerKO > wildKO, "which is more than the same wild one") + -- the receiver's own level still drives it, which is the whole point of + -- sharing to the party: the low member gains multiples of the high one + local lowKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 5 }, + isTrainer = true }, 1) + T.check(lowKO > trainerKO * 2, + "a level 5 party member takes several times what a level 30 one does " + .. "from the very same knockout") + + -- the combo ladder's five tiers + T.eq(LetsGo._comboMult(1), 1.1, "a fresh combo is the 1.1 tier") + T.eq(LetsGo._comboMult(10), 1.1, "which runs to ten") + T.eq(LetsGo._comboMult(11), 1.5, "eleven starts the 1.5 tier") + T.eq(LetsGo._comboMult(21), 2.0, "twenty-one the 2.0") + T.eq(LetsGo._comboMult(31), 2.5, "thirty-one the 2.5") + T.eq(LetsGo._comboMult(41), 3.0, "and forty-one caps it at 3.0") + T.eq(LetsGo._comboMult(999), 3.0, "where it stays") + + -- 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 + local m = Mat4.rotateZ(math.pi / 2) + -- row-major: x' of (1,0,0) is m[1], y' is m[5] + T.check(math.abs(m[1]) < 1e-9 and math.abs(m[5] - 1) < 1e-9, + "rotateZ carries +X onto +Y, the right-handed roll") + end + local ball = Pokeball.new("GREAT_BALL") + T.eq(ball.lid, 0, "a fresh ball is shut") + ball:open() + for _ = 1, 60 do ball:update(1 / 60) end + T.eq(ball.lid, 1, "open() hinges the lid fully over its ramp") + T.check(ball.glow < 1, "and the mouth glow decays on its own") + ball:close() + for _ = 1, 60 do ball:update(1 / 60) end + T.eq(ball.lid, 0, "close() brings it back") + local dur = ball:rock(1) + T.check(dur > 0, "a rock reports its duration for the caller's cadence") + T.check(ball:busy(), "and the ball is busy while it rocks") + for _ = 1, math.ceil(dur * 60) + 2 do ball:update(1 / 60) end + T.check(not ball:busy(), "then settles") + ball:catchClick() + T.check(ball.stars ~= nil, "the caught click spawns its stars") + local matrix = ball:matrix() + T.eq(#matrix, 16, "the pose is one model matrix") + for _ = 1, 120 do ball:update(1 / 60) end + T.check(ball.stars == nil, "and the stars burn out on their own") +end)() + Pipelines.reset() run.release() 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/letsgo_shots.lua b/tests/letsgo_shots.lua new file mode 100644 index 0000000..c42c22c --- /dev/null +++ b/tests/letsgo_shots.lua @@ -0,0 +1,309 @@ +-- Driver: photograph the LET'S GO capture mode end to end. +-- +-- One wild encounter under FULL: the battle menu should never be seen -- +-- capture mode opens over it with the ball hanging at the player's empty +-- cell and the ring pulsing on the foe -- then a synthetic mouse flick +-- throws the ball and the beats that follow are photographed: flight, +-- the open-mouth suck, the drop-and-wobble, and the outcome. +-- +-- SHOT_DIR=.scratchpad/letsgo \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_shots.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- SHOT_DIR must already exist. DS_LETSGO_MODE=catching runs the same +-- beats through the bag-menu route instead of the FULL auto-entry. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local MODE = os.getenv("DS_LETSGO_MODE") or "full" + 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") + + -- a spread of levels, because the Let's Go award pays every party + -- member against its OWN level: a low one should pull far more from + -- the same catch than a high one, and a flat payout would hide that + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 10), + Pokemon.new(game.data, "RATTATA", 5), + } + game.save.player.name = "RED" + Bag.add(game.save, "POKE_BALL", 20, game.data) + Bag.add(game.save, "GREAT_BALL", 5, game.data) + -- DS_BALL forces the ball, so a MASTER_BALL run is a guaranteed catch + -- and the experience payout can be read deterministically + local FORCE = os.getenv("DS_BALL") + if FORCE then + Bag.add(game.save, FORCE, 5, game.data) + CatchThrow.lastBall = FORCE + end + local expBefore = {} + for i, m in ipairs(game.save.party) do + expBefore[i] = { exp = m.exp, level = m.level } + end + LetsGo.setting:setValue(MODE, game) + -- DS_RUNG=cards forces the 2D-3D A rung: the control run for the pic + -- measurement path, against the STADIUM models the save may be on + 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())) + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(90) + + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe and the "Wild X appeared!" chatter: tap until the menu (or + -- capture mode, which under FULL opens the instant the menu would) + U.wait(70) + for i = 1, 80 do + if battle.phase == "menu" or CatchThrow.session() then break end + U.tap(game, "a") + U.wait(6) + if i % 20 == 0 then + U.log(("still driving intro: phase=%s queue=%d anim=%s") + :format(tostring(battle.phase), #(battle.queue or {}), + tostring(battle.animPlaying))) + end + end + + local function phase() + local s = CatchThrow.session() + return s and s.phase or ("battle:" .. tostring(battle.phase)) + end + + if MODE == "catching" then + -- the bag route: ITEM on the menu, then the ball + U.wait(20) + U.log("menu phase: " .. tostring(battle.phase)) + -- cursor to ITEM (menu is 2x2: fight pkmn / item run) -- down once + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(20) + -- the bag opens on the first item; balls were added first + U.tap(game, "a"); U.wait(10) + else + -- FULL: capture mode should have opened on its own + U.wait(30) + end + + U.log("capture phase: " .. phase()) + do + local s = CatchThrow.session() + if s then + local b = s.artBox + U.log(("probe: span=%.1f enemyGB=%.0f,%.0f playerGB=%.0f,%.0f") + :format(s.shot.enemySpan or -1, s.shot.enemy[1], s.shot.enemy[2], + s.shot.player[1], s.shot.player[2])) + U.log(("probe: eye=%.0f,%.0f,%.0f enemyW=%.0f,%.0f playerW=%.0f,%.0f") + :format(s.shot.eye[1], s.shot.eye[2], s.shot.eye[3], + s.enemyPos[1], s.enemyPos[3], + s.playerPos[1], s.playerPos[3])) + if b then + U.log(("probe: artBox ax=%d ay=%d box=%d..%d,%d..%d body r=%.1f yOff=%.1f") + :format(b.ax, b.ay, b.x0, b.x1, b.y0, b.y1, + s.body.r, s.body.yOff)) + else + U.log(("probe: artBox=nil body r=%.1f yOff=%.1f") + :format(s.body.r, s.body.yOff)) + end + end + end + U.shot(game, DIR .. "/1_aim.png") + + -- ------- the reach test + -- + -- Drag the ball to the far corners of the WINDOW -- including well + -- below where the battle's text box used to be -- and report where it + -- actually ends up in the GB frame. A fence shows up here as a ball + -- that stops moving while the pointer keeps going. + do + local W, H = love.graphics.getDimensions() + local s = CatchThrow.session() + if s and love.mousepressed then + local hx, hy = s.handGB[1], s.handGB[2] + love.mousepressed(W * 0.5, H * 0.62, 1, false, 1) + U.wait(2) + local CORNERS = { + { "bottom-centre", 0.50, 0.97 }, + { "bottom-left", 0.06, 0.97 }, + { "bottom-right", 0.94, 0.97 }, + { "top-left", 0.06, 0.10 }, + } + for _, c in ipairs(CORNERS) do + for i = 1, 4 do + if love.mousemoved then + love.mousemoved(W * c[2], H * c[3], 0, 0, false) + end + U.wait(1) + end + local q = CatchThrow.session() + U.log(("reach %-14s -> ball GB %.0f,%.0f (frame is 160x144)") + :format(c[1], q and q.handGB[1] or -1, q and q.handGB[2] or -1)) + if c[1] == "bottom-centre" then + U.shot(game, DIR .. "/1b_drag_low.png") + end + end + -- a slow release is a dead flick: nothing is thrown + if love.mousereleased then + love.mousereleased(W * 0.06, H * 0.10, 1, false, 1) + end + U.wait(30) + U.log("after reach test: " .. phase() + .. (" (ball home was %.0f,%.0f)"):format(hx, hy)) + end + end + + -- A synthetic flick aimed like a hand: from the session's own geometry, + -- pick a release point short of the ring and a sweep speed for a + -- comfortable sigma, so the aim model (release + reach along the flick) + -- lands the ball on the ring centre. Window units; the session maps + -- them back to GB through the live letterbox. + 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 + -- the throw is the swipe's own velocity now: grab the ball and sweep + -- toward the ring at a comfortable ~190 GB px/s for 8 frames. + -- DS_SWIPE overrides it, which is how the strength band is swept: + -- weak should fall short, comfortable should land, hard should still + -- reach rather than sail over. + local SPEED = tonumber(os.getenv("DS_SWIPE") or "") or 190 + local FRAMES = 8 + SPEED_USED = SPEED + 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 + U.log(("aim: hand %.0f,%.0f ring %.0f,%.0f outer %.0f") + :format(hx, hy, aim.ring[1], aim.ring[2], aim.outer)) + local step = SPEED / 60 + if love.mousepressed then + local px, py = toWin(hx, hy) + love.mousepressed(px, py, 1, false, 1) + end + for i = 1, FRAMES do + local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i) + if love.mousemoved then love.mousemoved(wx, wy, 0, 0, false) end + U.wait(1) + end + if love.mousereleased then + local wx, wy = toWin(hx + dx * step * FRAMES, hy + dy * step * FRAMES) + love.mousereleased(wx, wy, 1, false, 1) + end + -- what actually left the hand, before gravity has touched it + local s0 = CatchThrow.session() + if s0 and s0.vel then + local p = s0.ballInst.pos + U.log(("LAUNCH fwd/up/lat = %.1f %.1f %.1f from height %.1f, %.1f px out") + :format(math.sqrt(s0.vel[1] ^ 2 + s0.vel[3] ^ 2), s0.vel[2], 0, + p[2] - s0.groundY, + math.sqrt((s0.enemyPos[1] - p[1]) ^ 2 + + (s0.enemyPos[3] - p[3]) ^ 2))) + else + U.log("LAUNCH -- nothing was thrown (flick rejected)") + end + else + U.log("NO AIM INFO -- capture mode did not open") + end + + U.wait(4) + -- did it CONNECT? poll the flight out and say plainly which way it + -- went, so a strength sweep reads as a table instead of a guess + do + local verdict, peak = "no throw", 0 + for _ = 1, 200 do + local s = CatchThrow.session() + if not s then verdict = "session gone" break end + if s.phase == "flight" then + local h = s.ballInst.pos[2] - s.groundY + if h > peak then peak = h end + verdict = "MISS (fell short / wide)" + elseif s.phase == "suck" or s.phase == "drop" + or s.phase == "wobble" then + verdict = "HIT" .. (s.tier and (" " .. s.tier) or " (outside ring)") + break + elseif s.phase ~= "aim" then + break + end + U.wait(1) + end + local body = CatchThrow.session() and CatchThrow.session().body + U.log(("THROW swipe=%d -> %s (apex %.1f world px, body centre %.1f)") + :format(SPEED_USED or -1, verdict, peak, body and body.yOff or -1)) + end + U.log("after flick: " .. phase()) + U.shot(game, DIR .. "/2_flight.png") + + -- the suck: ball open at the foe, beam on, mon shrinking + for _ = 1, 60 do + U.wait(1) + local s = CatchThrow.session() + if s and s.phase == "suck" then break end + if not CatchThrow.session() then break end + end + U.log("suck check: " .. phase() + .. " shrink=" .. tostring(BattleScene.capture + and BattleScene.capture.shrink)) + U.shot(game, DIR .. "/3_suck.png") + + -- the drop and the first wobble + for _ = 1, 120 do + U.wait(1) + local s = CatchThrow.session() + if s and s.phase == "wobble" then break end + if not s then break end + end + U.wait(30) + U.log("wobble check: " .. phase() + .. " shrink=" .. tostring(BattleScene.capture + and BattleScene.capture.shrink)) + U.shot(game, DIR .. "/4_wobble.png") + + -- the outcome: stars or burst, then the engine's own text + for _ = 1, 300 do + U.wait(1) + local s = CatchThrow.session() + if not s or s.phase == "epilogue" or s.phase == "burst" then break end + end + U.wait(20) + U.log("outcome: " .. phase()) + U.shot(game, DIR .. "/5_outcome.png") + + -- under FULL a breakout must land straight back in throw mode -- no + -- enemy turn between; a catch plays out its epilogue instead + -- keep tapping through the caught chatter so storeCaughtMon (and the + -- experience payout hanging off it) actually runs + for _ = 1, 60 do U.tap(game, "a"); U.wait(6) end + U.log(("after outcome: %s battle=%s balls=%d") + :format(phase(), tostring(battle.phase), + game.save.inventory.POKE_BALL or 0)) + for i, m in ipairs(game.save.party) do + local was = expBefore[i] + local nm = game.data.pokemon[m.species].name + U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)") + :format(nm, was.level, m.level, was.exp, m.exp, m.exp - was.exp)) + end + local combo = lib.require("LetsGo").combo() + U.log("combo: " .. (combo and (combo.species .. " x" .. combo.count) + or "none")) + U.shot(game, DIR .. "/6_after.png") + U.log("done -- " .. DIR) +end diff --git a/tests/letsgo_trainer_exp.lua b/tests/letsgo_trainer_exp.lua new file mode 100644 index 0000000..f67c222 --- /dev/null +++ b/tests/letsgo_trainer_exp.lua @@ -0,0 +1,163 @@ +-- Driver: does a TRAINER knockout pay the whole party under LET'S GO FULL? +-- +-- The catch payout is easy to see (one throw, one award). A knockout is +-- not: it goes through the engine's own faint -> awardExp path, which is +-- the one this mode replaces wholesale. So fight a real trainer with a +-- deliberately uneven party and read the deltas -- every member should +-- gain, and the low ones should gain multiples of the high one. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_trainer_exp.lua \ +-- SHOT_DIR=.scratchpad/letsgo "/c/Program Files/LOVE/lovec.exe" . +-- +-- DS_LETSGO_MODE=off runs the same fight on the engine's own rules, which +-- is the control: there, only the Pokemon that fought should gain. +return function(game) + local U = dofile("tests/drivers/util.lua") + local MODE = os.getenv("DS_LETSGO_MODE") or "full" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + 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") + LetsGo.setting:setValue(MODE == "off" and false or MODE, game) + U.log("LET'S GO mode: " .. tostring(LetsGo.mode())) + + -- count the summary cards at their SOURCE rather than by catching them + -- on the stack: the driver taps fast enough to dismiss one between two + -- polls, and an absence there would look like a card that never came + local ExpPanel = lib.require("ExpPanel") + local innerNew, cards = ExpPanel.new, 0 + ExpPanel.new = function(g, rows) + local panel = innerNew(g, rows) + cards = cards + 1 + -- read at DRAW time in the real thing; here the loop that fills it + -- has already run, so peeking now is honest + local parts = {} + for _, r in ipairs(rows or {}) do + parts[#parts + 1] = ("%s+%d%s"):format( + (g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0, + (r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "") + end + U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " "))) + CARD_HOLD = 20 -- frames to stop tapping, so it can be shot + return panel + end + + -- one strong fighter and two bystanders: only the fighter gains on the + -- engine's own rules, so the bystanders ARE the test + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 10), + Pokemon.new(game.data, "RATTATA", 5), + } + game.save.player.name = "RED" + + -- The WEAKEST party in the game: one Pokemon, lowest level. Picked by + -- measuring rather than by name -- an alphabetical first pick lands on + -- OPP_AGATHA, whose Elite Four ghosts a level 45 Charizard does not + -- reliably clear, and a fight that never resolves reads exactly like a + -- payout that never happened. + local class, partyIx, best = nil, 1, nil + local ids = {} + for id in pairs(game.data.trainers) do + if type(id) == "string" and id:sub(1, 1) ~= "_" then ids[#ids + 1] = id end + end + table.sort(ids) -- stable across runs + for _, id in ipairs(ids) do + local rec = game.data.trainers[id] + for pi, party in ipairs((type(rec) == "table" and rec.parties) or {}) do + local n, top = 0, 0 + for _, mon in ipairs(party) do + n = n + 1 + top = math.max(top, tonumber(mon.level) or 0) + end + if n > 0 then + local score = n * 100 + top + if not best or score < best then + best, class, partyIx = score, id, pi + end + end + end + end + U.log(("fighting %s party %d (weakest of %d classes)") + :format(tostring(class), partyIx, #ids)) + + local before = {} + for i, m in ipairs(game.save.party) do + before[i] = { exp = m.exp, level = m.level } + end + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(60) + + local battle = BattleState.newTrainer(game, class, partyIx) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + + -- through the intro to the menu, then FIGHT + first move, over and + -- over until the fight resolves. The enemy's HP is logged as it goes, + -- so a fight that stalls is visibly a stall rather than a silent zero. + local lastHP, shotPanel = nil, false + for i = 1, 900 do + -- the fight ending does NOT end the loop until the card has been + -- photographed: the last knockout resolves the battle in the same + -- breath that queues the card, so breaking on `result` alone leaves + -- every run with the card built and never seen + if battle.result and shotPanel then break end + -- hold the taps while the card is up, so it can be shot rather than + -- dismissed on the next frame + if (CARD_HOLD or 0) > 0 then + CARD_HOLD = CARD_HOLD - 1 + if not shotPanel then + local top = game.stack and game.stack:top() + if top and rawget(top, "rows") then + shotPanel = true + U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad") + .. "/exp_panel.png") + U.log("shot the card") + end + end + U.wait(1) + elseif battle.phase == "menu" then + battle.menuIndex = 1 -- FIGHT + U.tap(game, "a") + elseif battle.phase == "moveSelect" then + battle.moveIndex = 1 + U.tap(game, "a") + else + U.tap(game, "a") + end + local hp = battle.enemy and battle.enemy.mon and battle.enemy.mon.hp + if hp ~= lastHP then + lastHP = hp + U.log((" foe HP -> %s (phase %s, step %d)") + :format(tostring(hp), tostring(battle.phase), i)) + end + -- photograph the summary card the moment it is on top, then let the + -- taps carry on and dismiss it + local top = game.stack and game.stack:top() + if top and not shotPanel and top ~= battle and rawget(top, "rows") then + shotPanel = true + U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad") + .. "/exp_panel.png") + U.log("shot the card") + end + U.wait(5) + end + U.log("battle result: " .. tostring(battle.result) + .. " phase=" .. tostring(battle.phase)) + + for i, m in ipairs(game.save.party) do + local was = before[i] + U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)") + :format(game.data.pokemon[m.species].name, was.level, m.level, + was.exp, m.exp, m.exp - was.exp)) + end + U.log("done") +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))