mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:40:51 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8804bfabaf | |||
| bed08c6c44 | |||
| 787d93a0e4 | |||
| c681167475 | |||
| 99de8c2760 | |||
| 34e7da5c12 | |||
| 9ddb940bc8 | |||
| c05c2cebd9 | |||
| d6a9f10352 | |||
| 13117e475b | |||
| 4aeb0fc675 | |||
| 9be6888290 | |||
| 6482325e26 | |||
| b8b2e0cd33 | |||
| bee22507b2 | |||
| 1f9a720625 | |||
| 55145e3faf | |||
| 0091e6d93b | |||
| 120f9716b6 | |||
| 322defbbd0 | |||
| eb69bc7db8 | |||
| b05c7265d6 | |||
| 6b4dfe6b9b | |||
| de54e4ea26 | |||
| 0d245c4ccb | |||
| 2907aba6ff | |||
| c07aed2449 | |||
| a3fb18a589 | |||
| 66da6cbc4b | |||
| 121c87b629 | |||
| dfe2f1ae1a | |||
| 41f1f07342 | |||
| 848c9cb29f | |||
| 87a6c03017 | |||
| f0d5ca570a | |||
| d5542f9518 | |||
| 5a4979303b | |||
| a46f194009 | |||
| 20f9e19bf9 |
+389
@@ -1,5 +1,394 @@
|
||||
# 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.
|
||||
|
||||
- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked
|
||||
through, so it opens on two opposite faces -- and the overworld drawing
|
||||
can only show one. The sprite is a facade seen face-on under a roof
|
||||
seen from above, so a SOUTH entrance is drawn (a doorway block in the
|
||||
facade's last rows, which `Structures` folds up into the front face)
|
||||
and a north, east or west one is drawn as nothing at all: the warp
|
||||
sits on the ground cell outside, the art beside it is plain wall.
|
||||
Top-down that reads fine, because the wall is never seen. In 3D you
|
||||
walked into a blank slab -- 33 of them, on eleven gates.
|
||||
|
||||
Each is now a real doorway, one cell of the tileset's own door art
|
||||
standing on the ground of the face you walk into, hung by the SAME
|
||||
rule the drawn facade hangs its own door by: the black frame stays
|
||||
flush with the wall and what it seals sinks a voxel behind it, so a
|
||||
side door and a front door are the same opening at the same depth, and
|
||||
the jambs the recess exposes fall out of the mesher wearing the
|
||||
frame's own texels. The art's outer ring is left alone -- a doorway
|
||||
cell is a cell OF a facade, its border is the wall beside and above
|
||||
the frame, and painting all 16x16 would stamp a one-pixel strip of
|
||||
front-wall art around every door. The same flood the facade tells wall
|
||||
from pane with, bounded to the cell, tells them apart here.
|
||||
|
||||
**Nothing is authored but the art.** `data/voxel_heights.lua` names
|
||||
one door cell per tileset and no coordinates: where the doors go is
|
||||
read off the map, from the warps that land in a gate house and the
|
||||
building standing against them. A warp is an entrance when it leads
|
||||
into a GATE-tileset map, is not already ON a door tile (a drawn south
|
||||
door would be fought over), and stands on a WALKABLE cell -- the ROM
|
||||
gives several gates an unreachable twin warp on the fence or tree
|
||||
beside the real opening, and a door behind a fence is a door into
|
||||
nothing. What is left is the entrance, and the two cells side by side
|
||||
that most gates do have come out as the double door they always were.
|
||||
So no hand list to drift out of step with a map edit, and every other
|
||||
building in the game is untouched: exactly the eleven gate placements
|
||||
get doors.
|
||||
|
||||
Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block
|
||||
is the gate on four routes and the warps sit at different rows of it
|
||||
on each -- so the model cache is keyed by the openings as well, and
|
||||
only placements that agree share a model. It costs about 290 quads a
|
||||
door (a flank quad carries one texel, so the art cannot merge into
|
||||
strips the way a facade's does) and no voxels: the recess removes as
|
||||
many faces as it exposes.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Dramatic Shape Voxel Mod
|
||||
|
||||
Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission.
|
||||
|
||||
A mod for the [Pokémon Gen 1 Recompilation
|
||||
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
||||
|
||||
@@ -24,6 +22,7 @@ menu.
|
||||
| `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models |
|
||||
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
|
||||
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
|
||||
| the **SHADOWS** options row | ON / OFF — real cast shadows, thrown by rendering the whole scene a second time from the sun, so buildings, trees, ledges and people shadow whatever they land on: walls, roofs, each other. The most expensive pass in the mode after the geometry itself, and the first thing to turn off on a phone or an old machine. **OFF** is no shadow at all — the flat drop shadows under characters included — and the forest's light shafts go with it, since the beams are lit by the sun's own map. **FULL** leaves it alone |
|
||||
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+157
-9
@@ -890,9 +890,8 @@ return {
|
||||
-- which has a PERSON drawn into the tile art -- becomes a monolith
|
||||
-- wearing his face.
|
||||
POKECENTER = {
|
||||
-- the wall band stays one 16px face: striped panels (40), the high
|
||||
-- windows (92-95; 94 doubles as the map's warp tile, and pins are
|
||||
-- look-only), the pokeball poster (2/3/18/19), and the pillars
|
||||
-- the wall band stays one 16px face: striped panels (40), the
|
||||
-- pokeball poster (2/3/18/19), and the pillars
|
||||
-- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
|
||||
-- trap and would recess into a pond lip). The healing machines'
|
||||
-- console face (76/77) and button panel (6/22) are ALSO wall:
|
||||
@@ -905,12 +904,37 @@ return {
|
||||
-- void rule flattens them. What is NOT wall is the machines'
|
||||
-- two flanks -- see `prop` below.
|
||||
wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
|
||||
76, 77, 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) with the nurse's
|
||||
-- tray (10), front face (24/25, the game's counterTiles), left end
|
||||
-- cap (56) and the Cable Club's light sections (90/91). 8px is
|
||||
-- one clean band, so the drawn front panel stands up and the
|
||||
-- counter top stays on top; at 12 they read as wall stubs
|
||||
76, 77 },
|
||||
-- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and
|
||||
-- (12,0) of every Center -- a flight going DOWN, away from the room,
|
||||
-- and the reason `stair_down_n` exists at all: the profile's other
|
||||
-- stairs run east or west and are drawn from the SIDE, where a drawn
|
||||
-- column is a step; these are drawn HEAD-ON, where a drawn row is,
|
||||
-- and no rotation of the east/west reading produces that.
|
||||
--
|
||||
-- The drawing is its own band table, and it lands exactly on an even
|
||||
-- four-step division of the cell: 4 white rows (the near tread), a
|
||||
-- black nosing, 3 grey, a nosing, 3 checker, then 4 black rows -- the
|
||||
-- dark the flight leaves into, which is also what the far end wall
|
||||
-- wears. Its first and last COLUMNS are the well's black side walls.
|
||||
-- Drawn row = depth row throughout; the rise is the only number the
|
||||
-- head-on view cannot state, and it takes the class height over the
|
||||
-- four steps like every other flight here.
|
||||
--
|
||||
-- Pinned as one cell (the class resolves off the top-left tile) but
|
||||
-- all four ids carry it, and the scan says they cannot reach anything
|
||||
-- else: 22 placements, exactly the two cells in each of the eleven
|
||||
-- Centers, and the Celadon Hotel on the same id places none of them.
|
||||
-- 94 is also the map's warp tile; pins are look-only, so the warp is
|
||||
-- untouched and the steps stay walk-through.
|
||||
stair_down_n = { 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) and the one cell of
|
||||
-- it that carries the push bell (10, lifted off as a figure below --
|
||||
-- the pin stays as the degradation path), front face (24/25, the
|
||||
-- game's counterTiles), left end cap (56) and the Cable Club's light
|
||||
-- sections (90/91). 8px is one clean band, so the drawn front panel
|
||||
-- stands up and the counter top stays on top; at 12 they read as
|
||||
-- wall stubs
|
||||
counter = { 8, 10, 24, 25, 56, 90, 91,
|
||||
-- and the lounge couch's SEAT column with the man
|
||||
-- sitting on it. Same half-cell box: its bottom row
|
||||
@@ -1035,6 +1059,74 @@ return {
|
||||
-- on the arm. The background corners around his head and the
|
||||
-- cushion wedge under his legs are the only pixels given back.
|
||||
figures = {
|
||||
-- THE PUSH BELL on the reception counter. One tile, $0A, drawn in
|
||||
-- the counter's TOP tile row at cell (3,2) -- the same cell in all
|
||||
-- eleven Centers and nowhere else on this id (scan: 11 hits, all
|
||||
-- tile (7,4)). Every other counter cell in the game runs 8 over
|
||||
-- 24/25; this one runs 8/10 over 24/25, and 10 is 8 with the bell
|
||||
-- painted into its east half.
|
||||
--
|
||||
-- It could not be a class pin: a pin resolves a whole 8x8 tile, and
|
||||
-- the tile is three quarters counter top. Pinned with the counter
|
||||
-- (which is what it was) the bell was just ink lying on the
|
||||
-- surface -- and lying on it TWICE, because the counter's one top
|
||||
-- row had to cover a 16px-deep plot and the mesher repeated it (see
|
||||
-- the half-cell rule in ChunkMesher: fixed, and the two stacked
|
||||
-- bells were what showed it).
|
||||
--
|
||||
-- So it is lifted off by mask, exactly like the Marts' till, and
|
||||
-- `under` puts plain 8 back -- the counter top the artist drew for
|
||||
-- every other cell of the same run, so nothing is synthesized and
|
||||
-- the surface closes up seamlessly.
|
||||
--
|
||||
-- Unlike the till it is NOT an extrusion of its drawing. Seven
|
||||
-- pixels by six of ¾-view dome state a round object and nothing
|
||||
-- else usable: every reading that turns six rows into geometry
|
||||
-- invents more than it measures. So the solid is AUTHORED (see
|
||||
-- TileShape's `model`) -- a 5x3 puck with its corners taken off,
|
||||
-- one voxel proud of the counter, with a single button voxel at
|
||||
-- its centre. `pixels` stays as the segmentation: it is what says
|
||||
-- where on the tile the bell is, and the model centres on it.
|
||||
--
|
||||
-- COLOUR is still not authored. Each layer names the texel its
|
||||
-- faces wear, and all four come off tile 8 -- the counter's own
|
||||
-- plain top, whose first rows are one flat shade each: row 0 its
|
||||
-- black back edge, row 1 its white highlight, row 5 its light
|
||||
-- band. So the puck's sides are the desk's own light shade, its
|
||||
-- top the desk's own white, and the button's sides the desk's own
|
||||
-- black, and all four follow every palette bake with it.
|
||||
--
|
||||
-- It stands at the FRONT of the counter cell: a service bell is on
|
||||
-- the customer's side of the desk, and this is the only object in
|
||||
-- the profile whose depth its drawing does not state. `inset` 2
|
||||
-- backs it off the counter's own front lip -- flush read as balanced
|
||||
-- on the edge; this is the number to move to slide it either way.
|
||||
{
|
||||
w = 1,
|
||||
inset = 2,
|
||||
tiles = { 10 },
|
||||
under = { 8 },
|
||||
model = {
|
||||
{ plan = { "0xxx0",
|
||||
"xxxxx",
|
||||
"0xxx0" },
|
||||
top = { 8, 1 }, side = { 8, 5 } },
|
||||
{ plan = { "00000",
|
||||
"00x00",
|
||||
"00000" },
|
||||
top = { 8, 1 }, side = { 8, 0 } },
|
||||
},
|
||||
pixels = {
|
||||
"........",
|
||||
"........",
|
||||
"...XXX..",
|
||||
"..XXXXX.",
|
||||
".XXXXXXX",
|
||||
".XXXXXXX",
|
||||
"..XXXXX.",
|
||||
"...XXX..",
|
||||
},
|
||||
},
|
||||
{
|
||||
w = 3,
|
||||
tiles = { 36, 37, 57,
|
||||
@@ -2976,6 +3068,62 @@ 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 },
|
||||
},
|
||||
|
||||
-- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH.
|
||||
--
|
||||
-- A route gate is walked THROUGH, so it has an opening on two opposite
|
||||
-- sides -- and the drawing can only show one of them. The overworld
|
||||
-- sprite is a facade seen face-on with a roof laid over it, so a south
|
||||
-- entrance is drawn (a doorway block in the facade's last rows, folded up
|
||||
-- by lib/Structures.lua) and a north, east or west one is drawn as
|
||||
-- NOTHING: the warp sits on the ground cell outside, the art beside it is
|
||||
-- plain wall, and top-down that reads fine because you never see the
|
||||
-- wall. In 3D you walk straight into a blank slab.
|
||||
--
|
||||
-- So the door is put back, on the face the player walks into. This names
|
||||
-- only the ART -- one 16x16 cell of the tileset's own doorway block, rows
|
||||
-- north-first, the same ids `frontOnly` above lists as facade-only.
|
||||
-- WHERE it goes is not authored at all: lib/Buildings.lua reads it off
|
||||
-- the map, from the warps that land in a gate and the building standing
|
||||
-- against them (see `sideDoors` there), because the map already states it
|
||||
-- and a hand list of thirty-odd coordinates would only be a chance to get
|
||||
-- one wrong.
|
||||
--
|
||||
-- OVERWORLD is the whole table because every such entrance in the game
|
||||
-- stands against an OVERWORLD building: the Safari Zone's north gate is a
|
||||
-- gap between two fence stubs with no drawing to carve, and the Route 22
|
||||
-- league gate on ROUTE_23 puts its warps on the road THROUGH the arch
|
||||
-- rather than against a wall. Both come out with no door, which is what
|
||||
-- they always had.
|
||||
sideDoors = {
|
||||
OVERWORLD = { { 11, 12 }, { 27, 28 } },
|
||||
},
|
||||
|
||||
-- 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
|
||||
|
||||
+140
-24
@@ -40,7 +40,6 @@ local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -48,6 +47,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 +217,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 +350,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 +422,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 +569,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 +596,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
|
||||
@@ -571,12 +639,10 @@ function BattleScene.render(state, arena, textures, token)
|
||||
local sunWas = Voxel3D.SHADOW_ALPHA
|
||||
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
|
||||
* DayNight.shadowScale(outdoor)
|
||||
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
|
||||
-- arena is a staged shot rather than the world being walked through, and
|
||||
-- the seams are what make it read as built rather than photographed. Forced
|
||||
-- through the override so the player's own row is never written to.
|
||||
local gridWas = VoxelGrid.override
|
||||
VoxelGrid.override = true
|
||||
-- The wireframe is whatever the V-GRID row says, exactly as it is out in
|
||||
-- the world (see VoxelGrid): the arena is drawn a unit per voxel like
|
||||
-- everything else, so the seams follow the one toggle and a player who
|
||||
-- turned them off does not get them back for the length of a fight.
|
||||
local out = nil
|
||||
local ok, err = pcall(function()
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
@@ -662,6 +728,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 +746,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 +790,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
|
||||
@@ -725,7 +842,6 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- renders (the free-roam pipeline, next frame) must find the orbit back
|
||||
Voxel3D.camera = nil
|
||||
Voxel3D.SHADOW_ALPHA = sunWas
|
||||
VoxelGrid.override = gridWas
|
||||
if not ok then
|
||||
-- endScene never ran, so the canvas is still bound and the shader still
|
||||
-- set; put the frame back the way it was found before rethrowing
|
||||
|
||||
+401
-10
@@ -134,9 +134,119 @@ local function profile()
|
||||
end
|
||||
|
||||
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
||||
local frontSets = {} -- tileset id -> { [tile] = true } or false
|
||||
|
||||
-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
|
||||
-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
|
||||
function Buildings.sideDoorCell(tilesetId)
|
||||
local s = profile()
|
||||
return s and s.sideDoors and s.sideDoors[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- 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 +259,83 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
||||
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
|
||||
-- is built from the complete drawing and the tower rises to its real
|
||||
-- height instead of folding as two half-buildings.
|
||||
local function read(t, data, perRow)
|
||||
-- Composite one doorway cell PAST the end of the sprite, at indices
|
||||
-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
|
||||
-- sprite indices like everything else -- `emit` resolves a voxel's colour
|
||||
-- through sp.ax/sp.ay and knows nothing about where the index came from --
|
||||
-- so the door only has to BE in the sprite arrays to travel the rest of
|
||||
-- the pipeline untouched. Appending rather than drawing into the grid is
|
||||
-- the point: the drawing itself must not change, or the silhouette flood,
|
||||
-- the taper and every measured band would be read off art the tileset
|
||||
-- never placed here.
|
||||
--
|
||||
-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
|
||||
-- flood both stop there), so the block is invisible to measurement and
|
||||
-- visible only to the code that asks for it by index.
|
||||
--
|
||||
-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
|
||||
-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
|
||||
-- ring is the wall beside and above the frame, and its last row is the
|
||||
-- black threshold the building stands on with the door's own step cut into
|
||||
-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
|
||||
-- front-wall art around every door.
|
||||
--
|
||||
-- The front facade tells the ring from the door by flooding: the wall
|
||||
-- around the door is one region with the whole facade, far too big to be a
|
||||
-- pane, and only what the black frame SEALS sinks. The same test, bounded
|
||||
-- to the block: flood the left, right and top edges through their own
|
||||
-- shade class, and what the flood reaches is context -- left unpainted, so
|
||||
-- the flank keeps the texel it already had. Not the bottom edge, because
|
||||
-- the bottom edge is the ground: the step under the door is sealed there
|
||||
-- on the drawn facade too, and it recesses with the rest of the doorway.
|
||||
--
|
||||
-- What is painted then splits the way a facade's does: black is frame and
|
||||
-- stays flush with the wall, everything it seals sinks a voxel behind it.
|
||||
local function readDoor(sp, data, perRow, cell)
|
||||
local base = sp.W * sp.H
|
||||
local black = {}
|
||||
for dy = 0, 15 do
|
||||
local row = cell[math.floor(dy / 8) + 1]
|
||||
for dx = 0, 15 do
|
||||
local tile = row[math.floor(dx / 8) + 1]
|
||||
local px = (tile % perRow) * 8 + dx % 8
|
||||
local py = math.floor(tile / perRow) * 8 + dy % 8
|
||||
local k = dy * 16 + dx
|
||||
local i = base + k
|
||||
sp.ax[i], sp.ay[i] = px, py
|
||||
local r, g, b, a = data:getPixel(px, py)
|
||||
sp.col[i] = shadeOf(r, g, b, a)
|
||||
sp.inside[i] = true
|
||||
black[k] = sp.col[i] == BLACK
|
||||
end
|
||||
end
|
||||
|
||||
local context, stack = {}, {}
|
||||
local function seed(dx, dy, cls)
|
||||
if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
|
||||
local k = dy * 16 + dx
|
||||
if context[k] or black[k] ~= cls then return end
|
||||
context[k] = true
|
||||
stack[#stack + 1] = k
|
||||
end
|
||||
for dy = 0, 15 do
|
||||
seed(0, dy, black[dy * 16])
|
||||
seed(15, dy, black[dy * 16 + 15])
|
||||
end
|
||||
for dx = 0, 15 do seed(dx, 0, black[dx]) end
|
||||
while #stack > 0 do
|
||||
local k = table.remove(stack)
|
||||
local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
|
||||
seed(dx + 1, dy, cls)
|
||||
seed(dx - 1, dy, cls)
|
||||
seed(dx, dy + 1, cls)
|
||||
seed(dx, dy - 1, cls)
|
||||
end
|
||||
|
||||
sp.door = { base = base, black = black, context = context }
|
||||
end
|
||||
|
||||
local function read(t, data, perRow, frontOnly)
|
||||
local tiles = t.tiles
|
||||
if t.topRows then
|
||||
tiles = {}
|
||||
@@ -244,7 +430,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 --
|
||||
@@ -983,11 +1170,113 @@ local function deskSetModel(sp, pr, t)
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- ------- the doorway a gate house is entered by from a side the drawing
|
||||
-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
|
||||
-- below for how the placements are found).
|
||||
--
|
||||
-- One cell of the tileset's own doorway art, standing on the ground of the
|
||||
-- face the player walks into, and hung by the SAME rule the drawn facade
|
||||
-- hangs its own door by: the art's black frame stays flush with the wall
|
||||
-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
|
||||
-- applied here by hand because the art is not in the drawing to be flooded
|
||||
-- with it). So a side door and a front door are the same depth of the same
|
||||
-- opening, and the jamb faces the recess exposes come out of the mesher for
|
||||
-- free, wearing the frame's own texels.
|
||||
--
|
||||
-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
|
||||
-- picks it per voxel, so which art column lands at which world coordinate
|
||||
-- is decided HERE and nowhere else -- and a face is read from outside, so
|
||||
-- the art's own left-to-right runs with the viewer's, not with the world's:
|
||||
-- facing east at a west wall, south is to your right (+z); facing west at
|
||||
-- an east wall, north is (-z); facing south at a north wall, west is (-x).
|
||||
-- Two of the three are mirrored against the axis, which is the same reason
|
||||
-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
|
||||
local DOOR = 16
|
||||
|
||||
local function sideDoors(at, sp, doors, W)
|
||||
if not (doors and #doors > 0 and sp.door) then return at end
|
||||
local art = sp.door
|
||||
|
||||
-- The face's OUTER surface, walked in from the box edge until the wall
|
||||
-- answers. A drawing inset from its own grid (B03's outer columns are
|
||||
-- terrain, not building) stands its flank a column or two in, and a door
|
||||
-- pinned to the box edge would hang in the air beside it.
|
||||
local function faceX(from, step, off)
|
||||
for k = 0, W - 1 do
|
||||
local x = from + step * k
|
||||
for y = 0, DOOR - 1 do
|
||||
for z = off, off + DOOR - 1 do
|
||||
if at(x, y, z) then return x end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local list = {}
|
||||
for _, d in ipairs(doors) do
|
||||
local face = 0 -- north: the facade's own z origin
|
||||
if d.side == "w" then face = faceX(0, 1, d.at)
|
||||
elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
|
||||
if face then
|
||||
list[#list + 1] = { side = d.side, off = d.at, face = face }
|
||||
end
|
||||
end
|
||||
if #list == 0 then return at end
|
||||
|
||||
-- art column at (x, z) for door `e`, or nil when the voxel is not in it
|
||||
local function column(e, x, z)
|
||||
if e.side == "n" then
|
||||
if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
|
||||
return e.off + DOOR - 1 - x, z == 0
|
||||
end
|
||||
elseif z >= e.off and z < e.off + DOOR then
|
||||
if e.side == "w" then
|
||||
if x == e.face then return z - e.off, true end
|
||||
if x == e.face + 1 then return z - e.off, false end
|
||||
else
|
||||
if x == e.face then return e.off + DOOR - 1 - z, true end
|
||||
if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return function(x, y, z)
|
||||
local v = at(x, y, z)
|
||||
-- no wall here is the end of it: a door is hung ON the building, and
|
||||
-- nothing about it may add geometry the drawing does not stand up
|
||||
if v == nil or y >= DOOR then return v end
|
||||
for _, e in ipairs(list) do
|
||||
local c, outer = column(e, x, z)
|
||||
if c then
|
||||
-- art row 0 is the door's head, so the ground row is its last
|
||||
local k = (DOOR - 1 - y) * DOOR + c
|
||||
-- the art's own ring: wall, not door. The flank keeps its texel.
|
||||
if art.context[k] then return v end
|
||||
if art.black[k] then
|
||||
-- the frame, flush with the wall; behind it the wall stands on
|
||||
if outer then return art.base + k end
|
||||
return v
|
||||
end
|
||||
-- and what the frame seals sinks: the face voxel goes, the one
|
||||
-- behind it wears the art. (Written long: `outer and nil or i`
|
||||
-- returns i for BOTH, nil being false to `and`.)
|
||||
if outer then return nil end
|
||||
return art.base + k
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
end
|
||||
|
||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||
-- are trimmed to its underside so nothing pokes through the surface.
|
||||
local function model(sp, pr, t)
|
||||
-- are trimmed to its underside so nothing pokes through the surface. A
|
||||
-- gate's side doors are hung on the finished lookup, last of all, because
|
||||
-- they answer to the FACE rather than to any band of the drawing.
|
||||
local function model(sp, pr, t, doors)
|
||||
if t.parts then return deskSetModel(sp, pr, t) end
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
@@ -1034,6 +1323,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 +1364,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,10 +1390,12 @@ 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
|
||||
|
||||
at = sideDoors(at, sp, doors, W)
|
||||
|
||||
return { at = at, W = W, ytop = ytop,
|
||||
zmin = ledge0 and -2 or 0,
|
||||
zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
|
||||
@@ -1307,6 +1607,82 @@ local function matches(S, t, tx, ty)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- which of a placement's faces a gate is entered by
|
||||
--
|
||||
-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
|
||||
-- gate house, and the face is whichever one of this placement the warp cell
|
||||
-- stands against. Thirty-odd doors fall out of two lines of geometry, and
|
||||
-- none of them can drift out of step with a map edit the way a hand list
|
||||
-- would. Three tests, each of them load-bearing:
|
||||
--
|
||||
-- the destination is a GATE tileset -- what makes a building a gate house
|
||||
-- rather than a house with a back door. GATE and FOREST_GATE both, so
|
||||
-- the Viridian Forest pair count; the Safari rest houses are on GATE
|
||||
-- too and are excluded by the next test, their warps being drawn doors
|
||||
-- already.
|
||||
-- the cell is not already a door tile -- a south entrance IS drawn, as a
|
||||
-- doorway block in the facade, and lib/Structures.lua folds it up into
|
||||
-- the front face. Adding a second one there would fight it.
|
||||
-- the cell is WALKABLE -- the ROM gives an unreachable twin warp to
|
||||
-- several gates (a fence cell beside the real opening on Route 7 west
|
||||
-- and Route 16 east, a tree beside Route 6's), and a door on the wall
|
||||
-- behind a fence is a door into nothing. The reachable cells are the
|
||||
-- entrance, and two of them side by side are the gate's real two-cell
|
||||
-- opening, which comes out as the double door it always was.
|
||||
--
|
||||
-- The south face is skipped whether or not it is drawn: it is the one face
|
||||
-- the drawing states in full, so anything it needs it already has.
|
||||
local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
-- through the module, NOT a global: `Game` is a local everywhere in the
|
||||
-- engine (`local Game = require("src.core.Game")` in a dozen files) and
|
||||
-- reading `_G.Game` came back nil every time -- which fails silently and
|
||||
-- exactly like the feature being off, because a nil map table is also
|
||||
-- what a headless build legitimately has.
|
||||
local ok, G = pcall(require, "src.core.Game")
|
||||
local defs = ok and G and G.data and G.data.maps
|
||||
local warps = map.def and map.def.warps
|
||||
if not (defs and warps and warps[1]) then return nil end
|
||||
if not Buildings.sideDoorCell(tileset.id) then return nil end
|
||||
|
||||
local out = nil
|
||||
for _, w in ipairs(warps) do
|
||||
local dest = defs[w.destMap]
|
||||
if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
|
||||
and map:isWalkableCell(w.x, w.y)
|
||||
and not map:isDoorTileCell(w.x, w.y) then
|
||||
-- the cell in the model's own pixels: a cell is two tiles, a tile
|
||||
-- eight pixels, and the placement's origin is (tx, ty) in tiles
|
||||
local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
|
||||
local side = nil
|
||||
if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
|
||||
elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
|
||||
elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
|
||||
if side then
|
||||
out = out or {}
|
||||
out[#out + 1] = { side = side, at = side == "n" and lx or lz }
|
||||
end
|
||||
end
|
||||
end
|
||||
if out then
|
||||
table.sort(out, function(a, b)
|
||||
if a.side ~= b.side then return a.side < b.side end
|
||||
return a.at < b.at
|
||||
end)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The model cache key's door half. A template's doors belong to the
|
||||
-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
|
||||
-- sit at different rows of it on each -- so two placements of one drawing
|
||||
-- are two models, and only placements that agree share one.
|
||||
local function doorKey(doors)
|
||||
if not doors then return "" end
|
||||
local parts = {}
|
||||
for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
|
||||
return "#" .. table.concat(parts, ",")
|
||||
end
|
||||
|
||||
-- Find every placement of every template for this map's tileset, build one
|
||||
-- model per template, and stamp it. Returns nothing; the quads land in
|
||||
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
|
||||
@@ -1327,6 +1703,9 @@ function Buildings.build(S, map, data, perRow)
|
||||
if type(t.tiles) == "table" and #t.tiles > 0 then
|
||||
local bh, bw = #t.tiles, #t.tiles[1]
|
||||
local first = t.tiles[1][1]
|
||||
-- the model this placement stamps. Not hoisted out of the loops any
|
||||
-- more: a template's doors belong to the placement, so two hits of
|
||||
-- one drawing on the same map can be two models (see doorKey).
|
||||
local built = nil
|
||||
for ty = 0, th - bh do
|
||||
Budget.tick()
|
||||
@@ -1352,8 +1731,13 @@ function Buildings.build(S, map, data, perRow)
|
||||
end
|
||||
end
|
||||
if free and matches(S, t, tx, ty) then
|
||||
if not built then
|
||||
local key = tileset.id .. ":" .. index
|
||||
do
|
||||
-- keyed per PLACEMENT once a gate's doors are in play (see
|
||||
-- doorKey): the drawing is shared, the openings are not
|
||||
local doors = not t.claimOnly
|
||||
and sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
or nil
|
||||
local key = tileset.id .. ":" .. index .. doorKey(doors)
|
||||
if not models[key] then
|
||||
if t.claimOnly then
|
||||
-- claim the cells, stamp nothing: the drawing here is
|
||||
@@ -1363,9 +1747,15 @@ 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))
|
||||
if doors then
|
||||
readDoor(sp, data, perRow,
|
||||
Buildings.sideDoorCell(tileset.id))
|
||||
end
|
||||
local pr = measure(sp, t)
|
||||
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
|
||||
models[key] = emit(model(sp, pr, t, doors), sp,
|
||||
atlasW, atlasH)
|
||||
end
|
||||
end
|
||||
built = models[key]
|
||||
@@ -1474,6 +1864,7 @@ end
|
||||
function Buildings.invalidate()
|
||||
spec = nil
|
||||
models = {}
|
||||
frontSets = {}
|
||||
end
|
||||
|
||||
return Buildings
|
||||
|
||||
+1770
File diff suppressed because it is too large
Load Diff
+103
-12
@@ -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")
|
||||
@@ -64,6 +65,26 @@ end
|
||||
|
||||
local ChunkMesher = {}
|
||||
|
||||
-- Which drawn row a FLAT-topped volume's top face wears at depth `ty`.
|
||||
--
|
||||
-- A structure is usually deeper than the art that draws it, so the rows
|
||||
-- cycle and the drawing repeats down the top. That is right for art which
|
||||
-- genuinely repeats -- the Safari Zone's fence alternates two tiles the
|
||||
-- whole way down -- and wrong for a RIM over a uniform body: a cliff
|
||||
-- mound's first row is its top edge, and cycling lays that edge again
|
||||
-- every second tile, striping a plateau with rims it should not have.
|
||||
--
|
||||
-- Where Structures found the body uniform, the rim is laid once at the
|
||||
-- north edge and the body held after it. Everything else cycles as before.
|
||||
function ChunkMesher.flatTopRow(run, ty)
|
||||
local m = math.min(2, run.extent)
|
||||
local d = ty - run.north
|
||||
if run.topUniform then
|
||||
return run.north + math.min(d, m - 1)
|
||||
end
|
||||
return run.north + (d % m)
|
||||
end
|
||||
|
||||
-- Ring of border blocks meshed around the body, matching the width
|
||||
-- TileRenderer draws so the two modes end at the same place.
|
||||
local RING = 3
|
||||
@@ -102,6 +123,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 +275,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
|
||||
@@ -370,8 +423,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
|
||||
-- `to` routes the quad somewhere other than the main sink -- the water
|
||||
-- surface is the only caller that ever does (see runGeometry's header).
|
||||
local function topQuad(x0, z0, h, tile, shade, to)
|
||||
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||
-- `vTop`/`vBot` crop the art to a row range of the tile, which only the
|
||||
-- half-cell furniture rule below ever asks for: a top band that has to
|
||||
-- cover more depth than it was drawn with hands each 8px cell its own
|
||||
-- slice of the band instead of the whole of it.
|
||||
local function topQuad(x0, z0, h, tile, shade, to, vTop, vBot)
|
||||
local u0, u1, v0, v1 = uvRect(tile, vTop or 0, vBot or 8)
|
||||
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||
@@ -528,11 +585,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
{ x0 + 8, neY, z0 }, { x0, nwY, z0 } },
|
||||
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95)
|
||||
elseif run then
|
||||
local m = math.min(2, run.extent)
|
||||
local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m))
|
||||
local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty))
|
||||
topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
|
||||
else
|
||||
local topTile = tile
|
||||
local vTop, vBot = nil, nil
|
||||
if s.art == "upright" and s.authored then
|
||||
-- Top art for a pinned box. A furniture drawing is top-view
|
||||
-- rows over floor(h/8) face-on rows the fold stands upright;
|
||||
@@ -559,6 +616,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
end
|
||||
end
|
||||
local row = math.min(ty, front - math.floor(h / 8))
|
||||
-- HALF-CELL FURNITURE, one cell of plot: the drawing gives ONE
|
||||
-- tile row of top view (the counter's surface) over one that
|
||||
-- folds up as the face (its front panel), and the plot under it
|
||||
-- is 16px deep. Repeating the top row over both depth rows --
|
||||
-- what `row` above resolves to, since the face row has no top
|
||||
-- art of its own to wear -- draws the surface TWICE: the
|
||||
-- Centers' counters ran a black back edge and its white
|
||||
-- highlight down the middle of every counter, and the push bell
|
||||
-- drawn on one of them came out as two bells stacked front to
|
||||
-- back. The band is foreshortened, not tiled, so each depth row
|
||||
-- takes HALF of it and the one drawing covers the whole top.
|
||||
--
|
||||
-- Deliberately narrow: only a run that is exactly one cell deep
|
||||
-- with exactly one top row. A deeper run states its own depth
|
||||
-- 1:1 already (the lounge couch is four tile rows over two
|
||||
-- cells, and its cushions must stay cushion-sized), and only the
|
||||
-- last of its rows repeats -- which is the drawing tiling, not
|
||||
-- a surface drawn once and stretched.
|
||||
local face = math.floor(h / 8)
|
||||
if front - face - north == 0 and front - north == 1 then
|
||||
local k = ty - north
|
||||
row = north
|
||||
vTop, vBot = k * 4, k * 4 + 4
|
||||
end
|
||||
if row < north then
|
||||
-- the whole run folded onto the face: top with the drawn
|
||||
-- row just above it when that row is furniture too (a
|
||||
@@ -577,7 +658,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- on the pond.
|
||||
topQuad(x0, z0, h, topTile,
|
||||
s.art == "upright" and VOLUME_TOP_SHADE or 1,
|
||||
(s.class == "water") and waterPush or nil)
|
||||
(s.class == "water") and waterPush or nil, vTop, vBot)
|
||||
end
|
||||
|
||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||
@@ -605,13 +686,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 +896,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 +923,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
|
||||
|
||||
@@ -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
|
||||
+460
@@ -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
|
||||
@@ -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
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+1624
-1527
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,464 @@
|
||||
-- This mod's settings, in categories, on menus of their own.
|
||||
--
|
||||
-- ------- why the flat list had to end
|
||||
--
|
||||
-- Every setting used to be spliced straight into the engine's OPTIONS list,
|
||||
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
|
||||
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
|
||||
-- was four screens of scrolling inside a list that already carried twenty
|
||||
-- engine rows -- and a player looking for SHADOWS had to know it was in there
|
||||
-- somewhere, past the wireframe and the horizon bend.
|
||||
--
|
||||
-- The engine has no grouping to borrow: a row descriptor is
|
||||
-- { id, label, value, step, activate } and nothing else. No headers, no
|
||||
-- sections, no pages. What it DOES have is `activate`, and a state stack that
|
||||
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
|
||||
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
|
||||
--
|
||||
-- ------- how the split was chosen
|
||||
--
|
||||
-- Not invented here: the mod already sorted its own settings, in the `full`
|
||||
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
|
||||
-- take away, and the reasoning written next to each one is always the same
|
||||
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
|
||||
-- the diorama FULL is a preset for.
|
||||
--
|
||||
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
|
||||
-- case to disappear under FULL: every child filters itself out and the
|
||||
-- category goes with them (see rows). PERFORMANCE is the three rows marked
|
||||
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
|
||||
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
|
||||
-- are the two features that are not about the look at all.
|
||||
--
|
||||
-- ------- what did NOT change
|
||||
--
|
||||
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
|
||||
-- row id, so options.lua is byte-identical for a player who upgrades and
|
||||
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
|
||||
-- identity a setting has. The hotkeys are untouched too, which is what makes
|
||||
-- the nesting affordable: a buried row is still one keypress away.
|
||||
|
||||
-- the mod namespace (see main.lua)
|
||||
local V = ...
|
||||
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SettingsMenu = {}
|
||||
SettingsMenu.__index = SettingsMenu
|
||||
|
||||
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
|
||||
-- covered, so there is no reason to pay for drawing it.
|
||||
SettingsMenu.isOpaque = true
|
||||
|
||||
SettingsMenu.ROOT = "root"
|
||||
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
|
||||
|
||||
-- Row ids live in a namespace of their own -- "menu." rather than a setting
|
||||
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
|
||||
-- settings rows have carried since the beginning.
|
||||
function SettingsMenu.id(catId)
|
||||
return "DRAMATIC_SHAPE:menu." .. catId
|
||||
end
|
||||
|
||||
-- ------- the categories, in menu order
|
||||
--
|
||||
-- `summary` is the second line of the category's own row, the way MODS reads
|
||||
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
|
||||
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
|
||||
-- current rung, which is the thing a player actually wants to know without
|
||||
-- opening it. Where no single row speaks for the rest, it counts them, which
|
||||
-- is honest rather than arbitrary.
|
||||
SettingsMenu.CATEGORIES = {
|
||||
{ id = "world", label = "3D WORLD..",
|
||||
help = "The diorama itself: how far the world bends, how much of it is "
|
||||
.. "drawn, what the water does and what hour it is outdoors." },
|
||||
{ id = "battles", label = "BATTLES..",
|
||||
summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
|
||||
help = "What a fight is drawn over, how it is framed, and how a ball is "
|
||||
.. "thrown." },
|
||||
{ id = "perf", label = "PERFORMANCE..",
|
||||
help = "What the look costs -- the three most expensive things in the "
|
||||
.. "frame after the geometry itself." },
|
||||
{ id = "vr", label = "VR..",
|
||||
summary = function() return V.require("VR").setting:valueLabel() end,
|
||||
help = "PCVR through OpenXR, and the one comfort setting that belongs to "
|
||||
.. "the headset alone." },
|
||||
}
|
||||
|
||||
-- ------- help for the rows that are not settings
|
||||
--
|
||||
-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
|
||||
-- next to the row it explains. What is left is the two pipeline rows -- whose
|
||||
-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
|
||||
-- and the ROM import, which is an action rather than a setting and has no
|
||||
-- SETTINGS entry to live in.
|
||||
local ROW_HELP = {
|
||||
["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
|
||||
.. "by a 3D camera, with the numbered rungs its angle in degrees.",
|
||||
["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
|
||||
.. "look, sharp across the middle and softening above and below it.",
|
||||
["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
|
||||
.. "cartridge that 3D-BTL's STADIUM rungs need.",
|
||||
}
|
||||
|
||||
-- ------- what the menus are built from
|
||||
--
|
||||
-- SETTINGS lives in main.lua, next to the help text that goes with each row
|
||||
-- and the comments explaining every `when` and `full`. It is handed here
|
||||
-- rather than moved, so this file stays about PRESENTATION and that one stays
|
||||
-- the single place the mod's settings are declared.
|
||||
local settings = {}
|
||||
local pipelineRows = {}
|
||||
|
||||
function SettingsMenu.define(list)
|
||||
settings = list or {}
|
||||
end
|
||||
|
||||
-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
|
||||
-- the category's out of CATEGORIES, or one of the three above for the rows
|
||||
-- that have nowhere else to keep it.
|
||||
--
|
||||
-- Looked up BY ID rather than hung on the row as a field, because two of
|
||||
-- these rows are the engine's own tables reused verbatim -- and annotating
|
||||
-- somebody else's table is how a mod ends up owning a field it never meant
|
||||
-- to. nil for a row with nothing to say, which SELECT reads as "no box".
|
||||
function SettingsMenu.helpFor(id)
|
||||
if ROW_HELP[id] then return ROW_HELP[id] end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
if SettingsMenu.id(cat.id) == id then return cat.help end
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
|
||||
-- .rows), captured by the options hook on its way past and shown here instead
|
||||
-- of at the top level. Reused verbatim, tables and all: they persist in
|
||||
-- save.options.pipelines through their own step functions, and rebuilding
|
||||
-- them here would be a second implementation of a thing the engine already
|
||||
-- got right.
|
||||
function SettingsMenu.setPipelineRows(rows)
|
||||
pipelineRows = rows or {}
|
||||
end
|
||||
|
||||
-- ------- a step here has the same consequences as a step anywhere
|
||||
--
|
||||
-- Two of these settings PIN something else when they change: 3D-BTL holds
|
||||
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
|
||||
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
|
||||
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
|
||||
-- pinning on its way past.
|
||||
--
|
||||
-- Nothing reruns that hook from in here, so the pin is asked for directly.
|
||||
-- main.lua supplies it, because WHICH values follow which is a question about
|
||||
-- the mod's settings and not about the menu they are on.
|
||||
local onChanged = nil
|
||||
|
||||
function SettingsMenu.setOnChanged(fn)
|
||||
onChanged = fn
|
||||
end
|
||||
|
||||
local function isFull()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
|
||||
end
|
||||
|
||||
-- The one rule that decides whether a setting is on a menu, lifted unchanged
|
||||
-- from the options hook it used to live in.
|
||||
--
|
||||
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||
-- with it. And a row whose own switch is off the table this frame (BACK
|
||||
-- SPRITES, which needs a staged fight to be about) is left off with it. The
|
||||
-- mod manager's page carries every one of them either way.
|
||||
local function offered(entry, full)
|
||||
return (entry.full or not full) and (not entry.when or entry.when())
|
||||
end
|
||||
|
||||
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
|
||||
-- no side effects -- so a caller that only wants to know what is on a menu
|
||||
-- (a test, or the root menu asking whether a category has anything in it)
|
||||
-- does not have to push a screen to find out.
|
||||
function SettingsMenu.rows(catId, game)
|
||||
local full = isFull()
|
||||
local out = {}
|
||||
if catId == SettingsMenu.ROOT then
|
||||
for _, row in ipairs(pipelineRows) do
|
||||
-- FULL owns the blur exactly as it owns the wireframe and the horizon
|
||||
-- bend, so T-SHIFT comes off with them
|
||||
if not (full and row.id == "pipeline:tiltshift") then
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
-- ------- settings that belong to no category
|
||||
--
|
||||
-- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
|
||||
-- menu next to the pipeline rows. For a setting that is about the GAME
|
||||
-- rather than about one of the four things the categories are for --
|
||||
-- SHINY ODDS is the first -- burying it under a heading it does not
|
||||
-- belong to is worse than the flat list this menu was built to end.
|
||||
--
|
||||
-- Above the categories, because these are rows you CHANGE and those are
|
||||
-- rows you OPEN: everything with a value on it stays together at the top
|
||||
-- of the screen, and the "..." rows read as the way further in.
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
local kids = SettingsMenu.rows(cat.id, game)
|
||||
-- An EMPTY category is not offered. This is the whole of what makes
|
||||
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
|
||||
-- special case, just nothing left inside to open.
|
||||
if kids[1] then
|
||||
out[#out + 1] = {
|
||||
id = SettingsMenu.id(cat.id),
|
||||
label = cat.label,
|
||||
value = cat.summary
|
||||
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
|
||||
activate = function(g)
|
||||
g.stack:push(SettingsMenu.new(g, cat.id))
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
-- ------- and the ROM import, last, on the top-level menu
|
||||
--
|
||||
-- An ACTION and not a setting: there is no rung to store, nothing for the
|
||||
-- mod manager's page to persist and nothing to restore on the next boot,
|
||||
-- so it is appended rather than living in SETTINGS.
|
||||
--
|
||||
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
|
||||
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
|
||||
-- and wait while it builds -- and a player who has been told to import a
|
||||
-- ROM should find the row where the mod begins, not two levels down a
|
||||
-- category they have no reason to open until it has worked. Last, because
|
||||
-- the categories are what the menu is FOR.
|
||||
local ok, importRow = pcall(function()
|
||||
return V.require("StadiumRomPick").row()
|
||||
end)
|
||||
if ok and importRow then out[#out + 1] = importRow end
|
||||
return out
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == catId and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- red ink for the mod's row on the OPTIONS menu
|
||||
--
|
||||
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
|
||||
-- nobody spends an afternoon on it. Twice over:
|
||||
--
|
||||
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
|
||||
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
|
||||
-- MULTIPLICATIVELY. black x red is black.
|
||||
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
|
||||
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
|
||||
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
|
||||
--
|
||||
-- What actually happens on this screen is that setColor picks a SHADE and the
|
||||
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
|
||||
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
|
||||
-- The engine does the same thing for the party menu's HP bars
|
||||
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
|
||||
SettingsMenu.INK = { 255, 0, 0 }
|
||||
|
||||
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
|
||||
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
|
||||
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
|
||||
-- the row is the same white as the row above it in all three.
|
||||
function SettingsMenu.redPalette(data)
|
||||
local base = PaletteFX.pal(data, "MEWMON")
|
||||
if not base then return nil end
|
||||
local out = { base[1], base[2], base[3], base[4] }
|
||||
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
|
||||
-- it the ink is the first slot and the paper the last. Put the red where it
|
||||
-- will land on the INK either way: without this the row draws as a solid
|
||||
-- red block with white letters cut out of it.
|
||||
--
|
||||
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
|
||||
-- outright and substitute their own, so the row simply draws monochrome --
|
||||
-- which is correct: the player asked for a screen with no colors in it.
|
||||
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
|
||||
return out
|
||||
end
|
||||
|
||||
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
|
||||
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
|
||||
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
|
||||
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
|
||||
-- black glyphs that would turn red along with the text if the band spanned
|
||||
-- the whole row.
|
||||
function SettingsMenu.rowZone(data, slot)
|
||||
local pal = SettingsMenu.redPalette(data)
|
||||
if not pal then return nil end
|
||||
local top = (slot - 1) * 4 + 1
|
||||
return PaletteFX.zone(pal, 2, top, 18, top + 1)
|
||||
end
|
||||
|
||||
-- ------- the screen
|
||||
--
|
||||
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
|
||||
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
|
||||
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
|
||||
-- level or the battle rows change -- which would replace a submenu's rows
|
||||
-- with the whole top-level OPTIONS list under the player's cursor. A state of
|
||||
-- our own cannot be caught by it.
|
||||
--
|
||||
-- It still renders through OptionRows, so it is the same four boxes, the same
|
||||
-- cursor and the same bottom line as every other menu in the game.
|
||||
function SettingsMenu.new(game, catId)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
cat = catId or SettingsMenu.ROOT,
|
||||
index = 1,
|
||||
scroll = 0,
|
||||
}, SettingsMenu)
|
||||
self.rows = SettingsMenu.rows(self.cat, game)
|
||||
self.sig = SettingsMenu.signature(self.rows)
|
||||
return self
|
||||
end
|
||||
|
||||
function SettingsMenu.signature(rows)
|
||||
local ids = {}
|
||||
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
|
||||
return table.concat(ids, "\1")
|
||||
end
|
||||
|
||||
-- The bottom line is the only place on this screen to say anything that is
|
||||
-- not a row: OptionRows' four boxes fill everything above it and there is no
|
||||
-- header slot. It spends that line on the two buttons that are not obvious.
|
||||
--
|
||||
-- It used to carry the category's NAME instead, for orientation. The hint
|
||||
-- won: a binding nobody knows about is worth nothing, and where the player is
|
||||
-- was just answered by the row they pressed A on. Sixteen characters of the
|
||||
-- eighteen the line has, which is also why the name could not stay -- "BACK:
|
||||
-- PERFORMANCE" is seventeen on its own.
|
||||
SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
|
||||
|
||||
function SettingsMenu:backLabel()
|
||||
return SettingsMenu.BACK_LABEL
|
||||
end
|
||||
|
||||
-- A category's contents can change while the player is looking at them: 3D-BTL
|
||||
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
|
||||
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
|
||||
-- actually differs, so the common case -- every other rung of every other row
|
||||
-- -- costs one string compare.
|
||||
function SettingsMenu:refresh()
|
||||
local rows = SettingsMenu.rows(self.cat, self.game)
|
||||
local sig = SettingsMenu.signature(rows)
|
||||
if sig == self.sig then return end
|
||||
-- Follow the row the cursor was ON rather than the slot it was in: a row
|
||||
-- can appear ABOVE the one just used, which would otherwise slide the
|
||||
-- cursor onto its neighbour. The bottom line follows itself.
|
||||
local wasBack = self.index > #self.rows
|
||||
local wasOn = self.rows[self.index] and self.rows[self.index].id
|
||||
self.rows, self.sig = rows, sig
|
||||
self.index, self.scroll = 1, 0
|
||||
if wasBack then
|
||||
self.index = #rows + 1
|
||||
else
|
||||
for i, row in ipairs(rows) do
|
||||
if wasOn and row.id == wasOn then self.index = i break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function pop(self)
|
||||
local stack = self.game and self.game.stack
|
||||
if self.game and self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
if stack and stack:top() == self then stack:pop() end
|
||||
end
|
||||
|
||||
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
|
||||
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
|
||||
-- alone, and the bottom line is a synthetic index past the end of the list
|
||||
-- rather than a row, so nothing a category contains can orphan the way out.
|
||||
function SettingsMenu:update()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local rows = self.rows
|
||||
local back = #rows + 1
|
||||
local changed = false
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
if self.index < 1 then self.index = back end
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
if self.index > back then self.index = 1 end
|
||||
elseif input:wasPressed("left") or input:wasPressed("right")
|
||||
or input:wasPressed("a") then
|
||||
local dir = input:wasPressed("left") and -1 or 1
|
||||
local row = rows[self.index]
|
||||
if row and row.activate then
|
||||
if input:wasPressed("a") then row.activate(self.game) end
|
||||
elseif row and row.step then
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
elseif input:wasPressed("select") then
|
||||
-- SELECT explains the row the cursor is on. Every row on these menus has
|
||||
-- something to say -- the settings have carried a paragraph each since
|
||||
-- they were written, and nothing has ever drawn it (see SettingsHelp) --
|
||||
-- but a row that does not is simply left alone rather than opening an
|
||||
-- empty box.
|
||||
local row = rows[self.index]
|
||||
local help = row and SettingsMenu.helpFor(row.id)
|
||||
if help and self.game.stack then
|
||||
self.game.stack:push(
|
||||
V.require("SettingsHelp").new(self.game, row.label, help))
|
||||
end
|
||||
return
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- B and START both, like every other menu -- and one level only: this
|
||||
-- pops US, leaving the OPTIONS menu underneath exactly as the player
|
||||
-- left it, with its own onCancel still to fire when they leave THAT.
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
if changed then
|
||||
-- before the rebuild, not after: pinning can itself change which rows are
|
||||
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
|
||||
-- it), and refresh has to see the settled answer
|
||||
if onChanged then pcall(onChanged, self.game) end
|
||||
if self.game.writeOptions then
|
||||
pcall(self.game.writeOptions, self.game)
|
||||
end
|
||||
end
|
||||
self:refresh()
|
||||
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
|
||||
#self.rows + 1)
|
||||
end
|
||||
|
||||
function SettingsMenu:draw()
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
|
||||
self:backLabel(), #self.rows + 1)
|
||||
end
|
||||
|
||||
-- REQUIRED, even though nothing here is red.
|
||||
--
|
||||
-- Game:draw walks the stack from the top and stops at the first state that
|
||||
-- HAS this method, not the first that answers something. Without one of our
|
||||
-- own the walk would fall through to the OPTIONS menu underneath -- whose
|
||||
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
|
||||
-- zone is addressed by SLOT, so it would land on whatever this menu happens
|
||||
-- to be showing in the same box.
|
||||
--
|
||||
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
|
||||
function SettingsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
return SettingsMenu
|
||||
+23
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Voxel world mode: whether the sun casts at all.
|
||||
--
|
||||
-- lib/ShadowMap renders the whole scene a second time from the light every
|
||||
-- frame the view or a pose changes, at up to 2048 squared, and every
|
||||
-- surface in the main pass then takes four taps at it. That is the single
|
||||
-- most expensive thing this mode does after the geometry itself -- and on a
|
||||
-- phone, or an old laptop, it is the difference between the diorama running
|
||||
-- and the diorama stuttering. So it gets a row.
|
||||
--
|
||||
-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
|
||||
-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
|
||||
-- a stand-in for a machine that wanted shadows and could not have them.
|
||||
-- A player who has just switched them off wants no shadow under anybody,
|
||||
-- which is what this row gives -- see ShadowMap.wanted, the one gate both
|
||||
-- halves hang off.
|
||||
--
|
||||
-- This file owns the toggle rather than the drawing: the value, where it
|
||||
-- persists, and the row the player finds it on -- exactly as VoxelGrid does
|
||||
-- for the wireframe.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local Shadows = {}
|
||||
|
||||
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||
-- OPTIONS and the mod manager's own settings page for this mod
|
||||
Shadows.KEY = "shadows"
|
||||
Shadows.LABEL = "SHADOWS"
|
||||
|
||||
-- ON is values[1] and so the default: cast shadows are what the mode is
|
||||
-- for as much as the geometry is -- a world where a building throws
|
||||
-- nothing reads as flat however many voxels it is made of. The row is for
|
||||
-- the machine that cannot carry them, not a look anybody is choosing.
|
||||
Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
|
||||
{ true, false }, { "ON", "OFF" })
|
||||
|
||||
function Shadows.enabled()
|
||||
return Shadows.setting:get() and true or false
|
||||
end
|
||||
|
||||
function Shadows.set(enabled, game)
|
||||
return Shadows.setting:setIndex(enabled and 1 or 2, game)
|
||||
end
|
||||
|
||||
function Shadows.toggle(game)
|
||||
return Shadows.setting:cycle(game)
|
||||
end
|
||||
|
||||
function Shadows.sync(value)
|
||||
Shadows.setting:sync(value and true or false)
|
||||
end
|
||||
|
||||
function Shadows.row()
|
||||
return Shadows.setting:row()
|
||||
end
|
||||
|
||||
return Shadows
|
||||
+289
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,323 @@
|
||||
-- The arrival sparkle, on the FLAT battle screen.
|
||||
--
|
||||
-- ------- why ShinyFx could not be reused
|
||||
--
|
||||
-- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it
|
||||
-- is about the 3D arena: it is armed from Stadium.update on the frame a
|
||||
-- side's model changes, it is sized from the model's own world height and
|
||||
-- radius, and it draws additive quads into the voxel scene through
|
||||
-- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no
|
||||
-- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards
|
||||
-- rather than building a model to measure.
|
||||
--
|
||||
-- So the effect was Stadium-only, and had been since it was written: ShinyFx
|
||||
-- .arm is called from exactly one file. On every other rung a shiny simply
|
||||
-- appeared, with no announcement. This is the announcement, in the one
|
||||
-- coordinate space those rungs share -- the Game Boy's own 160x144 grid,
|
||||
-- where the pic itself is drawn.
|
||||
--
|
||||
-- ------- the two slots
|
||||
--
|
||||
-- Both are the engine's, and neither moves: the enemy's front pic lives in
|
||||
-- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's
|
||||
-- back pic stands at x=8 with its feet on the text box at y=96, two-times
|
||||
-- scaled, so it fills y 32..96. The burst springs from a point inside each,
|
||||
-- a little above centre, which is roughly where a Pokemon's chest is in art
|
||||
-- drawn to fill its box.
|
||||
--
|
||||
-- Deliberately NOT measured off the drawn image. resolveBattleScale can
|
||||
-- rescale a pic per species, the send-out grow animates the scale from zero,
|
||||
-- and following either would make the burst jump around during exactly the
|
||||
-- moment it is playing. The slot is fixed; the sparkle uses the slot.
|
||||
--
|
||||
-- ------- black AND white, both
|
||||
--
|
||||
-- Each spark is drawn twice: a wider near-black cross, then a white one
|
||||
-- inside it. One colour alone would be invisible half the time -- the battle
|
||||
-- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D
|
||||
-- rungs composite the same pic over a sky or a map, where a black one does.
|
||||
-- The pair reads on both, and costs ten extra rectangles.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyFlash = {}
|
||||
|
||||
ShinyFlash.LIFE = 0.75 -- seconds, matching ShinyFx
|
||||
ShinyFlash.SPARKS = 9
|
||||
|
||||
-- The two slots, in GB pixels: where the burst starts and how far it travels.
|
||||
ShinyFlash.SLOTS = {
|
||||
enemy = { x = 124, y = 24, rx = 34, ry = 26 },
|
||||
player = { x = 40, y = 62, rx = 34, ry = 30 },
|
||||
}
|
||||
|
||||
-- Where each spark sits on the ring, as a fraction of a turn. Spread by hand
|
||||
-- rather than randomly: nine sparks on an even ring reads as a ring, and nine
|
||||
-- random ones read as a mess at this size. The half-step offset on alternate
|
||||
-- sparks keeps it from looking like a clock face.
|
||||
local ANGLES = {}
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0)
|
||||
/ ShinyFlash.SPARKS
|
||||
end
|
||||
|
||||
-- ------- why the clock is the WALL clock
|
||||
--
|
||||
-- Everything here happens on the DRAW side (see install), and a draw is
|
||||
-- handed no dt. Rather than accumulate one nobody offers, a burst records
|
||||
-- the time it started and its age is read back off love.timer.
|
||||
--
|
||||
-- That also makes it immune to being asked to draw more than once in a
|
||||
-- frame, which the wide layout does -- once per side -- and which a
|
||||
-- per-call dt accumulator would age at double speed.
|
||||
local function now()
|
||||
return (love.timer and love.timer.getTime and love.timer.getTime()) or 0
|
||||
end
|
||||
|
||||
-- live bursts: side -> the time it started
|
||||
local live = {}
|
||||
|
||||
-- what each side's pic was showing last frame, so an arrival is an EDGE
|
||||
local showing = {}
|
||||
|
||||
-- for the tests and the shot drivers, the way ShinyFx.debug is
|
||||
ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0,
|
||||
armed = 0, draws = 0, sparks = 0, err = "" }
|
||||
|
||||
function ShinyFlash.arm(side)
|
||||
live[side] = now()
|
||||
ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1
|
||||
end
|
||||
|
||||
function ShinyFlash.clear(side)
|
||||
live[side] = nil
|
||||
end
|
||||
|
||||
function ShinyFlash.reset()
|
||||
live, showing = {}, {}
|
||||
end
|
||||
|
||||
-- How far through its life this side's burst is, 0..1, or nil when there
|
||||
-- isn't one (or it has finished, which retires it on the way past).
|
||||
function ShinyFlash.age(side)
|
||||
local started = live[side]
|
||||
if not started then return nil end
|
||||
local u = (now() - started) / ShinyFlash.LIFE
|
||||
if u >= 1 then
|
||||
live[side] = nil
|
||||
return nil
|
||||
end
|
||||
return u
|
||||
end
|
||||
|
||||
function ShinyFlash.active(side)
|
||||
return ShinyFlash.age(side) ~= nil
|
||||
end
|
||||
|
||||
-- ------- is a Pokemon's own pic on screen for this side
|
||||
--
|
||||
-- The conditions are the engine's, read off drawPicsLayer rather than
|
||||
-- guessed: a side showing a TRAINER is showing a person and not a Pokemon,
|
||||
-- and the send-out, the faint fade and the safari/demo cases each have their
|
||||
-- own reason for the slot to be empty.
|
||||
--
|
||||
-- Returns the mon whose pic is up, or nil.
|
||||
function ShinyFlash.occupant(battle, side)
|
||||
if type(battle) ~= "table" then return nil end
|
||||
if side == "enemy" then
|
||||
if battle.showEnemyTrainer and battle.trainerPic then return nil end
|
||||
local b = battle.enemy
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.enemyHidden or battle.enemySendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
if battle.showPlayerBack and battle.playerBackPic then return nil end
|
||||
if battle.safari or battle.demo then return nil end
|
||||
local b = battle.player
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.sendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
|
||||
-- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a
|
||||
-- switch and a wild foe's first appearance alike, which is the same edge
|
||||
-- ShinyFx picks for the models.
|
||||
function ShinyFlash.follow(battle)
|
||||
ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = ShinyFlash.occupant(battle, side)
|
||||
if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end
|
||||
if mon ~= showing[side] then
|
||||
showing[side] = mon
|
||||
if mon and Shiny.isShiny(mon) then
|
||||
ShinyFlash.arm(side)
|
||||
else
|
||||
ShinyFlash.clear(side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Whole pixels. The screen this lands on is 160x144 and everything else in
|
||||
-- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing
|
||||
-- on a hard-edged frame.
|
||||
local function spark(px, py, arm)
|
||||
local g = love.graphics
|
||||
px, py = math.floor(px + 0.5), math.floor(py + 0.5)
|
||||
-- the dark cross first, one pixel proud of the light one on every side
|
||||
g.setColor(0, 0, 0, 1)
|
||||
g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3)
|
||||
g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.rectangle("fill", px - arm, py, arm * 2 + 1, 1)
|
||||
g.rectangle("fill", px, py - arm, 1, arm * 2 + 1)
|
||||
end
|
||||
|
||||
-- One side's burst, if it has one.
|
||||
function ShinyFlash.draw(side, sx, sy)
|
||||
local u = ShinyFlash.age(side)
|
||||
if not u then return end
|
||||
local slot = ShinyFlash.SLOTS[side]
|
||||
if not slot then return end
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
|
||||
-- Out and fading. The ring eases OUT rather than travelling at a constant
|
||||
-- speed -- fast off the mark, slow at the edge -- because a burst that
|
||||
-- decelerates reads as thrown and one that does not reads as a wipe.
|
||||
local ease = 1 - (1 - u) * (1 - u)
|
||||
local fade = 1 - u
|
||||
g.setColor(1, 1, 1, 1)
|
||||
ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1
|
||||
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
-- every third spark is held back a little, so the ring has some depth
|
||||
-- rather than nine points on one circle
|
||||
local lag = (i % 3 == 0) and 0.78 or 1
|
||||
local ang = ANGLES[i] * math.pi * 2
|
||||
local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag
|
||||
local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag
|
||||
-- arms shrink as the spark fades, so it goes out rather than vanishing
|
||||
local arm = 1 + math.floor(fade * 2.5)
|
||||
spark(px, py, arm)
|
||||
ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1
|
||||
end
|
||||
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- BEHIND the Pokemon, not over it
|
||||
--
|
||||
-- The burst springs from inside the mon and flies outward, so the frames that
|
||||
-- matter most are the ones where the ring is still small and sitting ON the
|
||||
-- body. Drawn from the overlay hook -- the end of the battle draw -- every one
|
||||
-- of those lands in FRONT of the pic, and the sparkle reads as stuck to the
|
||||
-- glass rather than as coming from the Pokemon.
|
||||
--
|
||||
-- So it is drawn from the PICS LAYER instead, before the engine's own pics go
|
||||
-- down. That is the only place in the frame that is behind the mon and in
|
||||
-- front of the field.
|
||||
--
|
||||
-- The overlay hook stays, and is still the only seam the 3D rungs have:
|
||||
-- OverworldBattle captured drawPicsLayer at install time and its battle draw
|
||||
-- calls the captured copy, so the wrap below never runs there. Whichever seam
|
||||
-- fires first draws; the other one sees the side already spent and leaves it
|
||||
-- alone. `spent` is cleared by the overlay, which is the one call guaranteed
|
||||
-- to happen exactly once per battle draw.
|
||||
local spent = {}
|
||||
|
||||
-- One side, unless it has already been drawn this frame.
|
||||
local function once(side, sx, sy)
|
||||
if spent[side] then return end
|
||||
spent[side] = true
|
||||
ShinyFlash.draw(side, sx, sy)
|
||||
end
|
||||
|
||||
-- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout
|
||||
-- drawing one side per call, and is honoured so the burst lands in the same
|
||||
-- pass its Pokemon does.
|
||||
--
|
||||
-- Skipped while the layer is SLIDING (the intro walks the whole battle in
|
||||
-- from the side): the slot this draws to is fixed, so a burst during the
|
||||
-- slide would sit still while the mon travelled past it. Nothing is lost --
|
||||
-- the arrival edge that arms it is after the slide is over.
|
||||
function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide)
|
||||
ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1
|
||||
ShinyFlash.follow(battle)
|
||||
if (slide or 0) ~= 0 then return end
|
||||
if onlySide ~= "player" then once("enemy", sx, sy) end
|
||||
if onlySide ~= "enemy" then once("player", sx, sy) end
|
||||
end
|
||||
|
||||
-- Follow the occupants and draw whatever the pics layer did not, in one call.
|
||||
function ShinyFlash.render(battle)
|
||||
ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1
|
||||
ShinyFlash.follow(battle)
|
||||
once("enemy", 0, 0)
|
||||
once("player", 0, 0)
|
||||
spent = {} -- one battle draw ends here; the next is new
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Through the engine's own `battle.overlay` hook, whose comment at the call
|
||||
-- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires
|
||||
-- at the very end of BattleState:draw, in the Game Boy's own 160x144 space,
|
||||
-- with the battle as its argument -- which is all this needs.
|
||||
--
|
||||
-- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth
|
||||
-- recording so it is not tried again: BattleState:update never fires during
|
||||
-- the intro, because the battle is not the top of the stack there and
|
||||
-- StateStack:update only calls the top. Measured -- installed, confirmed live
|
||||
-- on the class, zero calls -- rather than reasoned about.
|
||||
--
|
||||
-- The hook has no shake offset to give, and does not need one: it is called
|
||||
-- after the screen-shake translate has been popped, so nominal coordinates
|
||||
-- are the right ones.
|
||||
--
|
||||
-- ------- and the second seam, for depth
|
||||
--
|
||||
-- The overlay alone draws the burst OVER the Pokemon. The pics layer is
|
||||
-- wrapped as well so it can go down BEHIND it (see renderBehind), on every
|
||||
-- rung where the engine's own method is the one called. On the 3D rungs it is
|
||||
-- not -- OverworldBattle captured drawPicsLayer at install time and calls the
|
||||
-- captured copy -- and there the overlay is still the seam, which is why both
|
||||
-- are installed rather than one replacing the other.
|
||||
function ShinyFlash.install()
|
||||
local mod = V.mod
|
||||
if not (mod and mod.hooks and mod.hooks.wrap) then return false end
|
||||
if ShinyFlash.installed then return true end
|
||||
mod.hooks:wrap("battle.overlay", function(next, battle)
|
||||
local out = next(battle)
|
||||
local ok, err = pcall(ShinyFlash.render, battle)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return out
|
||||
end)
|
||||
|
||||
local okBS, BattleState = pcall(require, "src.battle.BattleState")
|
||||
if okBS and type(BattleState) == "table"
|
||||
and type(BattleState.drawPicsLayer) == "function"
|
||||
and not BattleState.dramaticShapeShinyFlash then
|
||||
local inner = BattleState.drawPicsLayer
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...)
|
||||
ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1
|
||||
local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy,
|
||||
onlySide)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return inner(self, slide, sx, sy, onlySide, ...)
|
||||
end
|
||||
BattleState.dramaticShapeShinyFlash = true
|
||||
end
|
||||
|
||||
ShinyFlash.installed = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyFlash
|
||||
+324
@@ -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
|
||||
@@ -0,0 +1,527 @@
|
||||
-- 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.
|
||||
-- ------- reading a SLIDE back out of a lookup table
|
||||
--
|
||||
-- A multiply was the first answer here and it is not good enough. Gyarados is
|
||||
-- the whole argument: its shiny is BLUE TURNING RED, and no multiply reaches
|
||||
-- red from blue -- it can only darken what is already there, so the most
|
||||
-- dramatic shiny in the game came out a dull mauve. That is the same ceiling
|
||||
-- the flat tint hit (see lib/ShinyPics.lua), reached from the other side.
|
||||
--
|
||||
-- But the table is not just a direction, it is the ANSWER: 1857 exact
|
||||
-- (normal -> shiny) pairs lifted from Stadium's own alternate textures. Read
|
||||
-- as HSL, each pair is a hue rotation, a saturation scale and a lightness
|
||||
-- step -- which is precisely the shape of a slide. So the five table species
|
||||
-- get a slide MEASURED from their own table rather than declared, and the one
|
||||
-- transform serves all 151.
|
||||
--
|
||||
-- Averaged over the pairs because a real alternate texture is not a perfect
|
||||
-- slide -- that is why it is a texture -- but it is close enough to one that
|
||||
-- the mean carries the change a player actually sees.
|
||||
--
|
||||
-- hue circularly (sum the unit vectors), or opposite rotations
|
||||
-- would cancel to "no change"
|
||||
-- saturation as GIMP's k, s2 = s1 * (1 + k), skipping near-grey pairs
|
||||
-- where the ratio is noise
|
||||
-- lightness as GIMP's two-sided k, matching shiftLight
|
||||
local slideCache = {}
|
||||
|
||||
local function slideFromLut(lut)
|
||||
local sx, sy, hueN = 0, 0, 0
|
||||
local sk, sn, lk, ln = 0, 0, 0, 0
|
||||
for key, val in pairs(lut) do
|
||||
local r1 = floor(key / 65536) % 256
|
||||
local g1 = floor(key / 256) % 256
|
||||
local b1 = key % 256
|
||||
local r2 = floor(val / 65536) % 256
|
||||
local g2 = floor(val / 256) % 256
|
||||
local b2 = val % 256
|
||||
local h1, s1, l1 = rgbToHsl(r1, g1, b1)
|
||||
local h2, s2, l2 = rgbToHsl(r2, g2, b2)
|
||||
-- an achromatic end has no hue, so the pair says nothing about rotation
|
||||
if s1 > 0.08 and s2 > 0.08 then
|
||||
-- DEGREES, both of them: rgbToHsl returns h*60 and hslToRgb takes
|
||||
-- `h % 360`, so the declared slides are in degrees too (-136 for
|
||||
-- Charizard) and a measured one has to come out in the same unit. It
|
||||
-- did not at first, and a rotation of 0.13 TURNS read as 0.13 degrees:
|
||||
-- Gyarados stayed blue and the whole point of measuring was lost.
|
||||
local d = math.rad(h2 - h1)
|
||||
sx, sy = sx + math.cos(d), sy + math.sin(d)
|
||||
hueN = hueN + 1
|
||||
sk, sn = sk + (s2 / s1 - 1), sn + 1
|
||||
end
|
||||
if l1 > 0.02 and l1 < 0.98 then
|
||||
lk = lk + (l2 < l1 and (l2 / l1 - 1) or ((l2 - l1) / (1 - l1)))
|
||||
ln = ln + 1
|
||||
end
|
||||
end
|
||||
local dh = 0
|
||||
if hueN > 0 and (sx * sx + sy * sy) > 1e-9 then
|
||||
dh = math.deg(math.atan2(sy, sx))
|
||||
end
|
||||
-- back into the -8..+8 STEPS the slide fields are in, so the value that
|
||||
-- comes out of here is the same kind of number as the 146 declared ones
|
||||
return {
|
||||
h = dh,
|
||||
s = sn > 0 and (sk / sn) / 0.125 or 0,
|
||||
l = ln > 0 and (lk / ln) / 0.125 or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- A transform for PALETTE colours rather than texture texels.
|
||||
--
|
||||
-- The two are not the same job. A lookup table answers only the colours that
|
||||
-- are IN it -- the ones its model is painted with -- and the engine's palettes
|
||||
-- are a different set entirely (BLUEMON's blue is not any blue on the
|
||||
-- Gyarados model), so the table asked to shift a palette returns it unchanged
|
||||
-- and the most dramatic shiny in the game comes out identical.
|
||||
--
|
||||
-- So: slide species use their declared slide, and table species use one
|
||||
-- measured out of their table by slideFromLut above. Both end up in the same
|
||||
-- HSL transform, which is the only kind that can rotate a hue.
|
||||
-- ------- and why the LIGHTNESS step is damped on a palette
|
||||
--
|
||||
-- A slide's l is authored against a TEXTURE: thousands of texels spread
|
||||
-- across the middle of the range, where "six steps darker" reads as a shadow
|
||||
-- falling over the animal. A Game Boy palette is not that. It is a four-shade
|
||||
-- RAMP from paper to ink, and only the middle two shades are the Pokemon --
|
||||
-- both already dark relative to the white they sit on, and both needing to
|
||||
-- stay clear of the fixed ink below them.
|
||||
--
|
||||
-- Applied whole, Golbat's -6 took its two shades to 27,42,37 and 34,58,52:
|
||||
-- correct green, and a green nobody can see against a 25,16,16 outline. Half
|
||||
-- the step keeps the direction and keeps the pic readable, which is the trade
|
||||
-- the ramp forces. Hue and saturation are untouched -- they are what makes a
|
||||
-- shiny recognisable as one, and neither collides with the paper or the ink.
|
||||
ShinyPalette.PALETTE_LIGHT_DAMP = 0.5
|
||||
|
||||
function ShinyPalette.paletteTransform(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
local slide = spec and spec.slide
|
||||
if not spec then return nil end
|
||||
if spec.lut then
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
slide = slideCache[dex] or nil
|
||||
end
|
||||
if not slide then return nil end
|
||||
return slideFn({
|
||||
h = slide.h or 0,
|
||||
s = slide.s or 0,
|
||||
l = (slide.l or 0) * ShinyPalette.PALETTE_LIGHT_DAMP,
|
||||
})
|
||||
end
|
||||
|
||||
-- The measured slide itself, for the tests and for anyone checking the five
|
||||
-- against Stadium's own textures.
|
||||
function ShinyPalette.lutSlide(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
if not (spec and spec.lut) then return nil end
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
return slideCache[dex] or nil
|
||||
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
|
||||
@@ -0,0 +1,156 @@
|
||||
-- A shiny's battle pic, genuinely recoloured.
|
||||
--
|
||||
-- ------- why the tint had to go
|
||||
--
|
||||
-- The first answer to "a shiny on the flat art" was a MULTIPLY at draw time,
|
||||
-- and it was the wrong shape twice over:
|
||||
--
|
||||
-- * A multiply can only DARKEN. Shiny Gyarados is blue turning RED; the
|
||||
-- nearest a multiply gets is a dimmer blue. Every species whose shiny is
|
||||
-- lighter, or is a hue rotation rather than a dimming, came out looking
|
||||
-- like the ordinary one with the brightness down -- which is exactly what
|
||||
-- "shinies don't work in 2D" describes.
|
||||
-- * It tinted the whole PICS LAYER, both sides at once, because that is the
|
||||
-- granularity the engine's own draw has. A shiny facing an ordinary mon
|
||||
-- dimmed its opponent too.
|
||||
--
|
||||
-- ------- where the colour actually lives
|
||||
--
|
||||
-- The battle pic is not drawn from four-shade art at play time. getImage
|
||||
-- (src/battle/BattleState.lua:147) snaps the four DMG shades to the species'
|
||||
-- palette ONCE, with mapPixel, and caches the finished image under
|
||||
-- `path .. "#" .. pal.name`. By the time anything is drawn the colour is
|
||||
-- already baked in, and the only way to change it is to hand that bake a
|
||||
-- different palette -- which also means a different cache NAME, or the shiny
|
||||
-- and the ordinary pic fight over one cache slot.
|
||||
--
|
||||
-- That is the whole of this file. It is the same conclusion ShinyUI reached
|
||||
-- for the status screen ("the palette is what has to move"), applied to the
|
||||
-- one other place a Pokemon is drawn flat.
|
||||
--
|
||||
-- ------- the seam
|
||||
--
|
||||
-- monPalette (BattleState.lua:216) is a local, so it cannot be wrapped. What
|
||||
-- it calls -- PaletteFX.monPal and PaletteFX.monPalName -- are not, and they
|
||||
-- are asked in that order for every battle pic the game builds.
|
||||
--
|
||||
-- Neither is told WHICH Pokemon is being drawn; both take a species. The
|
||||
-- individual arrives one call earlier, at the engine's own `pokemon.sprite`
|
||||
-- hook, which carries ctx.mon -- so the hook notes "the pic about to be built
|
||||
-- is this shiny mon's" and the two palette wraps consume that note. A flag
|
||||
-- rather than an argument, because the argument does not exist.
|
||||
--
|
||||
-- It is consumed ONCE, and matched on species as well, so a leak (monPalette
|
||||
-- returns early when a species has no palette at all, and then never asks for
|
||||
-- the name) cannot recolour somebody else's pic -- the worst case is one
|
||||
-- extra ordinary pic built under a shiny cache key, which the next call
|
||||
-- corrects.
|
||||
|
||||
-- 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 ShinyPics = {}
|
||||
|
||||
-- { species = <name>, dex = <n> } while a shiny's pic is being built
|
||||
local pending = nil
|
||||
|
||||
-- The suffix that makes the shiny pic its own cache entry. Part of the
|
||||
-- palette NAME rather than the path, because the name is what getImage keys
|
||||
-- on and the path is real art on disk that this mod does not add to.
|
||||
ShinyPics.SUFFIX = "-SHINY"
|
||||
|
||||
-- ------- what the sprite hook notices
|
||||
--
|
||||
-- Called for every battle pic the engine resolves. Returns nothing: the point
|
||||
-- is the note it leaves.
|
||||
function ShinyPics.note(ctx)
|
||||
pending = nil
|
||||
if type(ctx) ~= "table" or ctx.kind ~= "battle" then return end
|
||||
local mon = ctx.mon
|
||||
if not (mon and Shiny.isShiny(mon)) then return end
|
||||
local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return end
|
||||
pending = { species = ctx.species, dex = dex }
|
||||
end
|
||||
|
||||
-- Whether the pic currently being built is a shiny's -- for a test, and for
|
||||
-- the palette wraps below.
|
||||
function ShinyPics.pendingDex(species)
|
||||
if pending and pending.species == species then return pending.dex end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the palette wraps
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses.
|
||||
function ShinyPics.install()
|
||||
local ok, PaletteFX = pcall(require, "src.render.PaletteFX")
|
||||
if not ok or type(PaletteFX) ~= "table" then return false end
|
||||
if PaletteFX.dramaticShapeShiny then return true end
|
||||
local innerPal = PaletteFX.monPal
|
||||
local innerName = PaletteFX.monPalName
|
||||
if type(innerPal) ~= "function" or type(innerName) ~= "function" then
|
||||
return false
|
||||
end
|
||||
|
||||
function PaletteFX.monPal(data, species, transformed, ...)
|
||||
local cols = innerPal(data, species, transformed, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
if not (cols and dex) then
|
||||
-- nothing to recolour, and monPalette's early return means the name
|
||||
-- wrap below may never run: drop the note here rather than leave it
|
||||
-- for whoever asks next
|
||||
if not cols then pending = nil end
|
||||
return cols
|
||||
end
|
||||
local fn = ShinyPalette.paletteTransform(dex)
|
||||
if not fn then return cols end
|
||||
-- ------- the first and last shades DO NOT MOVE
|
||||
--
|
||||
-- A Game Boy mon palette is four shades and only the middle two are the
|
||||
-- Pokemon. The first is the PAPER -- 255,239,255 in every species'
|
||||
-- palette in the dataset, the white the pic sits on -- and the last is
|
||||
-- the INK, 25,16,16, the outline every pic is drawn with. Both are shared
|
||||
-- constants, not colours anybody chose for this animal.
|
||||
--
|
||||
-- Sliding them is what a shiny looks like when it is broken: shiny Golbat
|
||||
-- rotates far enough that its white became NAVY (31,34,93) and the pic
|
||||
-- read as a mon on a blue card rather than a green Golbat. Stadium's
|
||||
-- slides were authored for model textures, which have no paper and no
|
||||
-- outline in them, so there was nothing there to warn against it.
|
||||
--
|
||||
-- COPIED, never written through, for the rest. monPal hands back the
|
||||
-- dataset's own palette table, and mutating it would recolour every
|
||||
-- Pokemon of the species everywhere for the rest of the process -- the
|
||||
-- same trap ShinyUI's summary wrap documents.
|
||||
local last = #cols
|
||||
local out = {}
|
||||
for i, c in ipairs(cols) do
|
||||
if type(c) == "table" and c[1] and i > 1 and i < last then
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
out[i] = { r, g, b }
|
||||
else
|
||||
out[i] = c
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PaletteFX.monPalName(data, species, ...)
|
||||
local name = innerName(data, species, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
pending = nil -- consumed: one pic, one note
|
||||
if not (name and dex) then return name end
|
||||
if not ShinyPalette.paletteTransform(dex) then return name end
|
||||
return name .. ShinyPics.SUFFIX
|
||||
end
|
||||
|
||||
PaletteFX.dramaticShapeShiny = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyPics
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
-- 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()
|
||||
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 are NOT tinted here any more
|
||||
--
|
||||
-- There used to be a third wrap in this file: a multiply over
|
||||
-- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason
|
||||
-- is worth keeping so it is not put back.
|
||||
--
|
||||
-- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the
|
||||
-- nearest a multiply gets to that is a dimmer blue -- so every species whose
|
||||
-- shiny is lighter, or is a rotation rather than a dimming, read as the
|
||||
-- ordinary one with the brightness down. And the engine's pic layer draws
|
||||
-- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it.
|
||||
--
|
||||
-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where
|
||||
-- a battle pic's colour actually lives: getImage bakes the four DMG shades
|
||||
-- into the species palette once and caches the result, so handing that bake a
|
||||
-- shiny palette (under a cache name of its own) gives a genuinely recoloured
|
||||
-- pic -- brightening included -- for one side alone.
|
||||
--
|
||||
-- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for
|
||||
-- the per-side canvas, and they are the only tint left in the mod.
|
||||
|
||||
return ShinyUI
|
||||
+91
-3
@@ -51,6 +51,8 @@ local V = ...
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
local StadiumMon = V.require("StadiumMon")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyFx = V.require("ShinyFx")
|
||||
|
||||
local Stadium = {}
|
||||
|
||||
@@ -370,6 +372,15 @@ function Stadium.update(dt, battle, groundY)
|
||||
-- a fresh arrival: this Pokemon has not grown out of its ball yet
|
||||
if mon then mon.grow, mon.grewOwn = nil, nil end
|
||||
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
|
||||
-- and if it is shiny, announce it. This edge rather than the grow,
|
||||
-- because a WILD foe never grows -- it is on the field from the
|
||||
-- first frame -- and that is the encounter a shiny most wants to be
|
||||
-- announced on. See the header of ShinyFx.
|
||||
if ShinyBattle.battlerIsShiny(battler) then
|
||||
ShinyFx.arm(side)
|
||||
else
|
||||
ShinyFx.clear(side)
|
||||
end
|
||||
end
|
||||
-- the collapse this side is owed, once its bar has finished emptying
|
||||
if session.faintPending and session.faintPending[side] then
|
||||
@@ -381,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
|
||||
|
||||
+41
-2
@@ -26,6 +26,7 @@ local V = ...
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumFragment = V.require("StadiumFragment")
|
||||
local StadiumFx = V.require("StadiumFx")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local StadiumBuild = {}
|
||||
|
||||
@@ -661,7 +662,41 @@ function StadiumBuild.species(rom, fileno)
|
||||
local ctx = StadiumBuild.contextTable(rows, #data.anims)
|
||||
local bytes, height, floorY, radius =
|
||||
StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
return { species = species, bytes = bytes, height = height,
|
||||
|
||||
-- ------- and the shiny, from the same extraction
|
||||
--
|
||||
-- ORDER MATTERS AND IS THE WHOLE TRICK. The normal pack is written FIRST,
|
||||
-- off untouched texels, so `bytes` is bit-for-bit what it has always been
|
||||
-- and tests/stadium_extract_test.lua keeps diffing green against the
|
||||
-- Python oracle. Only then are the textures recoloured and the model
|
||||
-- packed a second time. The oracle knows nothing about shiny and does not
|
||||
-- need to: the format did not move, so there is no second implementation
|
||||
-- to keep in step and no DSM4.
|
||||
--
|
||||
-- Recolouring HERE rather than at load is what makes the effect textures
|
||||
-- separable. StadiumFx marks its generated frames `generated = true` and
|
||||
-- the packer drops the field, so this is the last moment a flame is
|
||||
-- distinguishable from a hide without inferring it back from the prim
|
||||
-- table. A shiny Charizard has a shiny hide and an ordinary fire.
|
||||
--
|
||||
-- Failure is not fatal: a species whose colours we lack, or a transform
|
||||
-- that throws, simply ships without a shiny variant and the runtime falls
|
||||
-- back to the normal model. Losing a recolour is a blemish; losing the
|
||||
-- install is a broken mod.
|
||||
local shinyBytes
|
||||
local ok, err = pcall(function()
|
||||
local spec = ShinyPalette.forDex(species)
|
||||
if not spec then return end
|
||||
if ShinyPalette.recolorTextures(data.textures, spec) == 0 then return end
|
||||
shinyBytes = StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
end)
|
||||
if not ok and V and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny recolour failed for species %d: %s",
|
||||
species, tostring(err))
|
||||
end
|
||||
|
||||
return { species = species, bytes = bytes, shinyBytes = shinyBytes,
|
||||
height = height,
|
||||
floor = floorY, radius = radius, bones = #data.bones,
|
||||
prims = #data.prims, anims = #data.anims,
|
||||
warnings = data.warnings }
|
||||
@@ -684,13 +719,17 @@ function StadiumBuild.job(rom, write, count)
|
||||
local fileno = self.done
|
||||
local ok, res, err = pcall(StadiumBuild.species, rom, fileno)
|
||||
if ok and res then
|
||||
local wrote, wErr = write(res.species, res.bytes)
|
||||
local wrote, wErr = write(res.species, res.bytes, res.shinyBytes)
|
||||
if not wrote then
|
||||
self.error = wErr or ("could not write species " .. res.species)
|
||||
self.done = self.total
|
||||
return false
|
||||
end
|
||||
self.bytes = self.bytes + #res.bytes
|
||||
if res.shinyBytes then
|
||||
self.bytes = self.bytes + #res.shinyBytes
|
||||
self.shiny = (self.shiny or 0) + 1
|
||||
end
|
||||
self.species = res.species
|
||||
else
|
||||
self.failed[#self.failed + 1] = fileno
|
||||
|
||||
+64
-11
@@ -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
|
||||
|
||||
|
||||
+71
-3
@@ -179,6 +179,7 @@ function StadiumMon.new(side)
|
||||
return setmetatable({
|
||||
side = side, -- "player" or "enemy"
|
||||
species = nil, -- the dex number currently modelled
|
||||
shiny = false, -- and whether it is the recoloured variant
|
||||
model = nil,
|
||||
rig = nil,
|
||||
state = "idle",
|
||||
@@ -195,6 +196,9 @@ end
|
||||
function StadiumMon:release()
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, nil
|
||||
-- cleared with the species: a stale true here would make the next
|
||||
-- setSpecies believe a shiny model was already loaded and early-return
|
||||
self.shiny = false
|
||||
end
|
||||
|
||||
-- ------- which species this side is showing
|
||||
@@ -222,14 +226,29 @@ end
|
||||
-- DATA rather than a list of dex numbers, so a future extraction bug that
|
||||
-- corrupts a species' idle falls back to the sprite instead of coming
|
||||
-- apart on the field -- and nothing here has to be edited when it does.
|
||||
function StadiumMon:setSpecies(dex)
|
||||
if dex == self.species then return self.rig ~= nil end
|
||||
--
|
||||
-- `shiny` is part of the IDENTITY, not a flag applied afterwards. The early
|
||||
-- return below is keyed on it for that reason: a shiny Rattata and an
|
||||
-- ordinary one share a dex number but are different models, loaded from
|
||||
-- different packs, and comparing on the dex alone would keep whichever
|
||||
-- loaded first and colour both sides with it. That is precisely the shape
|
||||
-- of bug the two-Rattata note above describes, and it is silent -- the
|
||||
-- model is valid, it is simply the wrong one.
|
||||
function StadiumMon:setSpecies(dex, shiny)
|
||||
shiny = shiny and true or false
|
||||
if dex == self.species and shiny == (self.shiny or false) then
|
||||
return self.rig ~= nil
|
||||
end
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, dex
|
||||
self.shiny = shiny
|
||||
self.grow, self.grewOwn = nil, nil
|
||||
if not dex then return false end
|
||||
local model = StadiumPack.load(dex)
|
||||
local model = StadiumPack.load(dex, shiny)
|
||||
if not model then return false end
|
||||
-- the pack falls back to the normal model when a species has no shiny
|
||||
-- variant, so believe the model rather than the request
|
||||
self.shiny = model.shiny and true or false
|
||||
if model.staticPose then return false end
|
||||
local rig = StadiumRig.new(model)
|
||||
if not rig then return false end
|
||||
@@ -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
|
||||
|
||||
+88
-22
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+251
-3
@@ -2004,7 +2004,9 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
local north = s.class == "stair_down_n"
|
||||
local down = north or s.class == "stair_down_e"
|
||||
or s.class == "stair_down_w"
|
||||
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
||||
local mx, mz = cx * 16, cy * 16
|
||||
local h = s.h or 16
|
||||
@@ -2053,6 +2055,90 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
end
|
||||
end
|
||||
|
||||
-- A flight running INTO the map instead of across it. The drawing is
|
||||
-- the same staircase seen head-on rather than from the side, and that
|
||||
-- changes which axis of the art means what: a drawn ROW is a step here,
|
||||
-- and -- because looking down a well is looking along its depth -- drawn
|
||||
-- row IS depth row, 1:1 across the cell's 16.
|
||||
--
|
||||
-- The Centers' steps state their own band table and it lands exactly:
|
||||
-- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So
|
||||
-- an even four-step division puts a black NOSING on the southmost row of
|
||||
-- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
|
||||
-- tread. Nothing is authored but the RISE, which no head-on drawing can
|
||||
-- state; the depths, the treads and the nosings are all measured.
|
||||
--
|
||||
-- A nosing is drawn as one row because it is seen nearly edge-on, so
|
||||
-- un-projected it has real height and no depth: its row lies flat as the
|
||||
-- tread's front lip AND stands as the riser under it. That is the one
|
||||
-- texel in the flight used twice, and using it twice is what a nosing is.
|
||||
--
|
||||
-- The well's own walls come free as well: the drawing's first and last
|
||||
-- COLUMNS are its black side walls, and its top band is the darkness the
|
||||
-- flight leaves by, which is what the far end wants to wear.
|
||||
--
|
||||
-- Every quad here is split at the cell's own 8px seam, in x and in rows
|
||||
-- both: `uv` resolves ONE tile per corner, and these four tiles are not
|
||||
-- neighbours in the atlas, so a quad that spans a seam interpolates
|
||||
-- between two unrelated corners of the sheet.
|
||||
if north then
|
||||
local runD = 16 / STAIR_STEPS
|
||||
local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local a0 = 16 - (i + 1) * runD -- band i, in art rows
|
||||
local a1 = a0 + runD
|
||||
local yTop = -(i + 1) * rise
|
||||
local z0b, z1b = mz + a0, mz + a1
|
||||
|
||||
for _, H in ipairs(HALVES) do
|
||||
local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
|
||||
|
||||
-- the tread: the whole band, drawn row = depth row, so the nosing
|
||||
-- lies on its front lip exactly where the artist drew it
|
||||
face({ wx0, yTop, z0b }, { wx1, yTop, z0b },
|
||||
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
ax0, a1, ax1, a0, STAIR_SHADE.wellTread)
|
||||
|
||||
-- the riser under that lip. It faces NORTH -- a flight descending
|
||||
-- away from you turns its risers away with it, and they close the
|
||||
-- steps from below rather than being looked at. One art row tall,
|
||||
-- so it needs none of `banded`'s row splitting; written straight
|
||||
-- keeps the geometry flush at the seam while the art stays inside
|
||||
-- its tile
|
||||
local ry = -i * rise
|
||||
face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
{ wx0, ry, z1b }, { wx1, ry, z1b },
|
||||
ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
|
||||
|
||||
-- the deep end, closing the opening this flight is cut into: from
|
||||
-- the floor of the well up to the top of the wall band beside it,
|
||||
-- in the drawing's own black top rows
|
||||
if i == STAIR_STEPS - 1 then
|
||||
face({ wx1, -h, mz }, { wx0, -h, mz },
|
||||
{ wx0, h, mz }, { wx1, h, mz },
|
||||
ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd)
|
||||
end
|
||||
end
|
||||
|
||||
-- the well's side walls above this tread, wearing the drawing's own
|
||||
-- black edge columns -- the excavation is walled in its own texels
|
||||
local function sideWall(px, sx0, sx1, inward)
|
||||
local c
|
||||
if inward then -- west wall, faces E
|
||||
c = { { px, yTop, z1b }, { px, yTop, z0b },
|
||||
{ px, 0, z0b }, { px, 0, z1b } }
|
||||
else -- east wall, faces W
|
||||
c = { { px, yTop, z0b }, { px, yTop, z1b },
|
||||
{ px, 0, z1b }, { px, 0, z0b } }
|
||||
end
|
||||
face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
|
||||
end
|
||||
sideWall(mx, 0.1, 1.3, true)
|
||||
sideWall(mx + 16, 14.7, 15.9, false)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
|
||||
local sx1 = sx0 + runW
|
||||
@@ -2152,6 +2238,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
||||
-- box or floor it. A rising flight stands on the map's common
|
||||
-- floor; a stairwell IS the hole, so nothing is painted under it
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
or s.class == "stair_down_n"
|
||||
for dy = 0, 1 do
|
||||
for dx = 0, 1 do
|
||||
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
||||
@@ -2253,6 +2340,43 @@ function Structures.buildVolume(S, map, tiles)
|
||||
-- whether the region's dominant columns are flat repeats (a cliff
|
||||
-- mound's plateau) rather than drawn facades (a house's front)
|
||||
local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN
|
||||
|
||||
-- Whether this REGION's tops are a rim over a uniform body -- what every
|
||||
-- cliff mound is drawn as: a top edge, then the same rock the whole way
|
||||
-- down. The top face may then lay that rim once along its north edge and
|
||||
-- hold the body after it, instead of cycling the rim back every second
|
||||
-- tile and striping a plateau with edges it should not have.
|
||||
--
|
||||
-- Answered per column AND per region, because each catches what the
|
||||
-- other misses. A mound is one structure many columns wide, and the
|
||||
-- columns carrying its cave mouth read differently from their neighbours
|
||||
-- (their drawing ends in the mouth's own tiles): per column alone, those
|
||||
-- kept cycling while the rest held, leaving rim stubs above the doorway.
|
||||
-- But a region vote alone silences a genuine rim-over-body column that
|
||||
-- happens to stand in a region of repeating art -- three of them in the
|
||||
-- Safari Zone. A column holds if EITHER says so.
|
||||
--
|
||||
-- Art that genuinely repeats is not uniform and keeps cycling: the
|
||||
-- Safari Zone's fence alternates two tiles the whole way down, and there
|
||||
-- the repeat IS what the drawing says.
|
||||
local uniformVotes, uniformTotal = 0, 0
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
if run.extent > 2 then
|
||||
uniformTotal = uniformTotal + 1
|
||||
local body = map:tileAt(r.tx, run.north + 1)
|
||||
local uniform = true
|
||||
for d = 2, run.extent - 1 do
|
||||
if map:tileAt(r.tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
run.ownUniform = uniform
|
||||
if uniform then uniformVotes = uniformVotes + 1 end
|
||||
end
|
||||
end
|
||||
local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
local h = run.unit * 8
|
||||
@@ -2300,6 +2424,7 @@ function Structures.buildVolume(S, map, tiles)
|
||||
run.rise = roofRows * 8
|
||||
run.peak = h
|
||||
run.h = h - run.rise -- facade height: what sides build to
|
||||
run.topUniform = run.ownUniform or regionUniform
|
||||
for ty = run.north, run.front do
|
||||
S.runs[keyOf(r.tx, ty)] = run
|
||||
end
|
||||
@@ -3051,6 +3176,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
|
||||
end
|
||||
end
|
||||
|
||||
-- An AUTHORED solid standing on furniture, given as plan layers instead of
|
||||
-- extruded from the drawing (see TileShape's `model`). The one thing it
|
||||
-- shares with the mask paths is that nothing here is a colour: each layer
|
||||
-- names the atlas texels its top and its sides wear, and every quad below
|
||||
-- samples one of them, so the Centers' bell is painted out of the counter's
|
||||
-- own pixels and recolours with it.
|
||||
--
|
||||
-- Placement is by CELL, not by drawn row. A model exists because the
|
||||
-- drawing was too small to un-project, so its drawn row says nothing about
|
||||
-- depth worth keeping -- what says something is which piece of furniture it
|
||||
-- is on and which end of it a person reaches: the solid is centred on the
|
||||
-- mask's own columns and pushed to the SOUTH edge of the support cell, the
|
||||
-- face the aisle is on, less the entry's `inset` -- the one number here
|
||||
-- taste can move, because flush against the counter's own front lip is a
|
||||
-- real position and so is a couple of voxels back from it.
|
||||
local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0)
|
||||
local function uvOf(t)
|
||||
local tile, row, col = t[1], t[2], t[3] or 0
|
||||
return ((tile % perRow) * 8 + col + 0.5) / atlasW,
|
||||
(math.floor(tile / perRow) * 8 + row + 0.5) / atlasH
|
||||
end
|
||||
|
||||
for k, L in ipairs(m) do
|
||||
local u, v = uvOf(L.side)
|
||||
local ut, vt = uvOf(L.top)
|
||||
local above = m[k + 1]
|
||||
local x0 = xMid - math.floor(L.w / 2)
|
||||
local z0 = zSouth - L.d
|
||||
local function solid(layer, dx, dz)
|
||||
if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then
|
||||
return false
|
||||
end
|
||||
return layer.cells[dz * layer.w + dx] or false
|
||||
end
|
||||
for dz = 0, L.d - 1 do
|
||||
for dx = 0, L.w - 1 do
|
||||
if solid(L, dx, dz) then
|
||||
local x, y, z = x0 + dx, y0 + k - 1, z0 + dz
|
||||
local function quad(c1, c2, c3, c4, uu, vv, shade)
|
||||
quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv,
|
||||
shade = shade }
|
||||
end
|
||||
-- a layer's own plan is what closes it: a face is drawn wherever
|
||||
-- the neighbouring cell of this layer is empty, and the top
|
||||
-- wherever the layer ABOVE does not stand on it. Nothing needs a
|
||||
-- bottom -- layer 1 rests on the furniture and the rest rest on
|
||||
-- each other.
|
||||
if not solid(above, dx, dz) then
|
||||
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
|
||||
{ x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top)
|
||||
end
|
||||
if not solid(L, dx, dz + 1) then
|
||||
quad({ x, y, z + 1 }, { x + 1, y, z + 1 },
|
||||
{ x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v,
|
||||
OBJ_SHADE.front)
|
||||
end
|
||||
if not solid(L, dx, dz - 1) then
|
||||
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
|
||||
{ x + 1, y + 1, z }, u, v, OBJ_SHADE.back)
|
||||
end
|
||||
if not solid(L, dx - 1, dz) then
|
||||
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
|
||||
{ x, y + 1, z }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
if not solid(L, dx + 1, dz) then
|
||||
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
|
||||
{ x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
|
||||
|
||||
-- One authored figure at one matched position.
|
||||
@@ -3133,7 +3332,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
|
||||
if fig.depth then
|
||||
if fig.model then
|
||||
-- An authored solid: centred on the mask's own columns, standing on
|
||||
-- the furniture's top plane at the front of its cell.
|
||||
local maxX = minX
|
||||
for ly = 0, bh - 1 do
|
||||
for lx = 0, bw - 1 do
|
||||
if at(lx, ly) and lx > maxX then maxX = lx end
|
||||
end
|
||||
end
|
||||
local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2)
|
||||
local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0)
|
||||
maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH,
|
||||
xMid, zSouth, baseY)
|
||||
elseif fig.depth then
|
||||
-- An OBJECT: the standee slab, standing on the FRONT edge of the tile
|
||||
-- row its feet are drawn in -- the south face of the 8px band a
|
||||
-- character card would have pivoted in. It is anchored there and
|
||||
@@ -3508,13 +3720,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
|
||||
|
||||
@@ -125,6 +125,12 @@ local FALLBACK_HEIGHTS = {
|
||||
stair_w = 16,
|
||||
stair_down_e = 16,
|
||||
stair_down_w = 16,
|
||||
-- a stairwell descending toward the BACK of the map, drawn head-on
|
||||
-- instead of from the side (the Centers' Cable Club steps). Its own
|
||||
-- class because the art reading is not the east/west one turned: there
|
||||
-- a drawn COLUMN is a step and a drawn row is height, here a drawn ROW
|
||||
-- is a step and drawn row = depth row, 1:1 down the well
|
||||
stair_down_n = 16,
|
||||
}
|
||||
|
||||
-- class -> how the mesher draws it (see the header). The last three are
|
||||
@@ -215,6 +221,7 @@ local ART = {
|
||||
stair_w = "stair",
|
||||
stair_down_e = "stair",
|
||||
stair_down_w = "stair",
|
||||
stair_down_n = "stair",
|
||||
}
|
||||
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
@@ -469,6 +476,8 @@ end
|
||||
--
|
||||
-- figures = { { w = <tiles across>,
|
||||
-- depth = <voxels of body; ABSENT for a person>,
|
||||
-- model = { ...authored plan layers, bottom first... },
|
||||
-- inset = <voxels back from the support cell's front>,
|
||||
-- thin = { rows = <top rows>, depth = <voxels> },
|
||||
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
@@ -488,6 +497,16 @@ end
|
||||
-- same furniture the card would have stood on. The Marts' cash
|
||||
-- register is the case: a machine on a counter is a box, not an icon.
|
||||
--
|
||||
-- `model` is the third answer, and the only one that is not an extrusion
|
||||
-- of the drawing at all: an AUTHORED solid, given as plan layers bottom
|
||||
-- first, standing at the FRONT of the support cell. It exists for a
|
||||
-- drawing too small to un-project -- the Centers' push bell is 7x6 pixels
|
||||
-- of ¾-view dome, and no reading of six rows produces a shape a mask can
|
||||
-- extrude without inventing more than it measures. What it still may not
|
||||
-- invent is COLOUR: each layer names the atlas texel its top and its
|
||||
-- sides wear, so the solid is painted out of the drawing it replaces and
|
||||
-- follows every palette bake exactly like the rest of this file.
|
||||
--
|
||||
-- Two fields say which parts of such a drawing are NOT the extrusion,
|
||||
-- because a solid drawn in one 16x16 GB cell still packs more than one
|
||||
-- facing:
|
||||
@@ -557,10 +576,43 @@ local function authoredMasks(list)
|
||||
r0 = math.floor(f.flat.rows[1]),
|
||||
r1 = math.floor(f.flat.rows[2]) }
|
||||
end
|
||||
-- an AUTHORED model: plan layers bottom-first, each with the atlas
|
||||
-- texel its top and its sides wear. Dropped whole on any malformed
|
||||
-- layer, like every other field here -- a typo should leave the
|
||||
-- drawing lying flat, not build half a solid.
|
||||
local model = nil
|
||||
if type(f.model) == "table" and #f.model > 0 then
|
||||
model = {}
|
||||
for _, L in ipairs(f.model) do
|
||||
local plan = type(L) == "table" and L.plan
|
||||
local mw = (type(plan) == "table" and type(plan[1]) == "string")
|
||||
and #plan[1] or 0
|
||||
local okL = mw > 0 and type(L.top) == "table"
|
||||
and type(L.side) == "table"
|
||||
if okL then
|
||||
for _, r in ipairs(plan) do
|
||||
if type(r) ~= "string" or #r ~= mw then okL = false break end
|
||||
end
|
||||
end
|
||||
if not okL then model = nil break end
|
||||
local cells = {}
|
||||
for dz = 0, #plan - 1 do
|
||||
local r = plan[dz + 1]
|
||||
for dx = 0, mw - 1 do
|
||||
if r:sub(dx + 1, dx + 1) ~= "0" then cells[dz * mw + dx] = true end
|
||||
end
|
||||
end
|
||||
model[#model + 1] = { w = mw, d = #plan, cells = cells,
|
||||
top = L.top, side = L.side }
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under,
|
||||
depth = depth and math.floor(depth) or nil,
|
||||
model = model,
|
||||
inset = model and math.floor(tonumber(f.inset) or 0)
|
||||
or nil,
|
||||
thin = thin, flat = flat }
|
||||
end
|
||||
end
|
||||
|
||||
+73
-1
@@ -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
|
||||
|
||||
+140
-3
@@ -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.
|
||||
@@ -411,12 +503,22 @@ local active = false
|
||||
-- which is exactly the old behaviour minus the reflections.
|
||||
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
|
||||
|
||||
-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more:
|
||||
-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound
|
||||
-- together must agree on PIXEL dimensions. The colour canvas beside this one
|
||||
-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1
|
||||
-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this
|
||||
-- one came back 2.625x larger and the pair would not bind. beginScene then
|
||||
-- dropped the readable depth for the session (see below), depthReadable()
|
||||
-- went false, and the water pass never ran at all: the reflections were
|
||||
-- missing on every high-density display, with nothing in the log to say so,
|
||||
-- because a canvas that will not BIND is not a canvas the driver refused.
|
||||
local function newDepth(w, h)
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local c = nil
|
||||
for _, format in ipairs(DEPTH_FORMATS) do
|
||||
local ok, made = pcall(love.graphics.newCanvas, w, h,
|
||||
{ format = format, readable = true })
|
||||
{ format = format, readable = true, dpiscale = 1 })
|
||||
if ok and made then c = made break end
|
||||
end
|
||||
if not c then return nil end
|
||||
@@ -505,6 +607,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 +1182,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 +1213,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).
|
||||
@@ -1238,7 +1354,10 @@ end
|
||||
function Voxel3D.beginWater(paint)
|
||||
if not (active and canvas and held and held.depth) then return nil end
|
||||
if not held.mirror then
|
||||
local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
|
||||
-- through PixelCanvas, because this one is bound WITH held.depth a few
|
||||
-- lines down and the two must agree on pixel dimensions -- the same
|
||||
-- scale trap newDepth documents
|
||||
local ok, c = PixelCanvas.new(held.w, held.h)
|
||||
if not (ok and c) then return nil end
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
pcall(c.setWrap, c, "clamp", "clamp")
|
||||
@@ -1380,6 +1499,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)
|
||||
|
||||
+6
-12
@@ -61,19 +61,13 @@ end
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- A pass that needs the wireframe whatever the player left the row on sets
|
||||
-- this for the length of its own draw and puts it back after. nil means
|
||||
-- "follow the setting", which is every frame outside such a pass.
|
||||
--
|
||||
-- The overworld battle is the one user: a fight is a STAGED shot, not the
|
||||
-- world being walked around in, and the seams are what make it read as
|
||||
-- constructed rather than as a photograph of somewhere. The row still owns
|
||||
-- what free-roam looks like, and is not written to -- switching the mode off
|
||||
-- mid-battle would silently rewrite the player's own setting.
|
||||
VoxelGrid.override = nil
|
||||
|
||||
-- The row is the whole answer, everywhere: free-roam and the battle arena
|
||||
-- alike. The battle used to force the seams on regardless -- a fight is a
|
||||
-- STAGED shot, and the seams are what make it read as constructed rather
|
||||
-- than photographed -- but a player who turns the wireframe off means the
|
||||
-- whole mod, and a mode that came back for every fight read as the row not
|
||||
-- working rather than as a deliberate framing.
|
||||
function VoxelGrid.enabled()
|
||||
if VoxelGrid.override ~= nil then return VoxelGrid.override end
|
||||
return VoxelGrid.setting:get() and true or false
|
||||
end
|
||||
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -88,15 +88,24 @@ 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 ShinyPics = V.require("ShinyPics")
|
||||
local ShinyFlash = V.require("ShinyFlash")
|
||||
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 +113,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 +210,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 +336,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 +457,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 +518,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 +581,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 +768,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 +862,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 +907,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 +961,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 +1065,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 +1127,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 +1169,60 @@ 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 status page's mark, and the summary pic's palette
|
||||
-- ShinyPics the battle pic's palette -- a real recolour, baked into the
|
||||
-- image cache under a shiny key, on every rung that draws a
|
||||
-- pic (OFF, both 2D-3D rungs, and the cards a STADIUM battle
|
||||
-- still uses for a species with no model)
|
||||
-- ShinyFx the arrival sparkle for the STADIUM rungs (3D, armed from
|
||||
-- Stadium.update)
|
||||
-- ShinyFlash the same announcement for every OTHER rung, drawn in the
|
||||
-- Game Boy's own pixel grid over the pic
|
||||
--
|
||||
-- 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()
|
||||
ShinyPics.install()
|
||||
ShinyFlash.install()
|
||||
|
||||
-- ShinyPics needs to know WHICH Pokemon a pic is being built for, and the
|
||||
-- two palette functions it wraps are told only the species. The individual
|
||||
-- passes through here one call earlier: `pokemon.sprite` carries ctx.mon.
|
||||
--
|
||||
-- next() first and the return value untouched -- this reads the context and
|
||||
-- changes nothing about which art is chosen.
|
||||
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
|
||||
local out = next(path, ctx)
|
||||
pcall(ShinyPics.note, ctx)
|
||||
return out
|
||||
end)
|
||||
|
||||
-- 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 +1303,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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.7.2",
|
||||
"version": "1.8.1",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
+827
-100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
-- A flat top must not stamp its rim twice.
|
||||
--
|
||||
-- ChunkMesher.flatTopRow decides which drawn row a flat-topped volume's top
|
||||
-- face wears at each depth. Where the drawing is a RIM over a uniform body
|
||||
-- -- every cliff mound in the game, and the mound the Diglett's Cave mouth
|
||||
-- is cut into -- the rim belongs at the plateau's north edge and nowhere
|
||||
-- else. Cycling the first two rows lays it again every second tile.
|
||||
--
|
||||
-- The invariant: on such a run the sampled row never goes BACKWARDS as ty
|
||||
-- moves south. Art that genuinely repeats (the Safari Zone's fence
|
||||
-- alternates two tiles the whole way down) is exempt: there the repeat is
|
||||
-- what the drawing says, and the run is not rim-over-body.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/flat_top_test.lua lovec .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local V = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
V = V and V.lib
|
||||
local Structures = V and V.require("Structures")
|
||||
local ChunkMesher = V and V.require("ChunkMesher")
|
||||
if not (Structures and ChunkMesher and ChunkMesher.flatTopRow) then
|
||||
print("[flattop] FAIL mod, Structures or ChunkMesher.flatTopRow missing")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
|
||||
|
||||
local MAPS = {}
|
||||
for id in pairs((game.data and game.data.maps) or {}) do
|
||||
MAPS[#MAPS + 1] = id
|
||||
end
|
||||
table.sort(MAPS)
|
||||
|
||||
local checked, offenders, examples = 0, 0, {}
|
||||
for _, mapId in ipairs(MAPS) do
|
||||
U.teleport(game, mapId, 5, 5, "up")
|
||||
U.wait(6)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.def and ow.map.def.id == mapId then
|
||||
local map = ow.map
|
||||
local S = Structures.forMap(map)
|
||||
local seen = {}
|
||||
for tx = 0, map.def.width * 4 - 1 do
|
||||
for ty = 0, map.def.height * 4 - 1 do
|
||||
local run = S.runs[keyOf(tx, ty)]
|
||||
local sig = run and (tostring(run) .. ":" .. tx)
|
||||
if run and not seen[sig] and (run.rise or 0) == 0 then
|
||||
seen[sig] = true
|
||||
local ext = run.front - run.north + 1
|
||||
-- rim over a uniform body: the shape the rim must not repeat on
|
||||
local uniform = ext > 2
|
||||
if uniform then
|
||||
local body = map:tileAt(tx, run.north + 1)
|
||||
for d = 2, ext - 1 do
|
||||
if map:tileAt(tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if uniform then
|
||||
checked = checked + 1
|
||||
local prev = -1
|
||||
for ty2 = run.north, run.front do
|
||||
local row = ChunkMesher.flatTopRow(run, ty2)
|
||||
if row < prev then
|
||||
offenders = offenders + 1
|
||||
if #examples < 5 then
|
||||
examples[#examples + 1] = ("%s tx=%d north=%d ext=%d "
|
||||
.. "went back to row %d at ty %d")
|
||||
:format(mapId, tx, run.north, ext, row, ty2)
|
||||
end
|
||||
break
|
||||
end
|
||||
prev = row
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
print(("[flattop] %d rim-over-body runs checked, %d repeat their rim")
|
||||
:format(checked, offenders))
|
||||
for _, e in ipairs(examples) do print("[flattop] " .. e) end
|
||||
if offenders > 0 then
|
||||
print("[flattop] FAIL")
|
||||
love.event.quit(1)
|
||||
else
|
||||
print("[flattop] PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Driver: is a shiny visible on the FLAT paths -- the engine's own battle
|
||||
-- screen (3D-BTL OFF) and the cards rung (2D-3D A)?
|
||||
--
|
||||
-- DS_SHOTS=mods/DramaticShapeVoxelMod/.claude/shiny_update/flat \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_flat.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- The other shot driver covers the cards rung and the STADIUM rungs and NOT
|
||||
-- 3D-BTL OFF -- which is the rung a player who has never touched the mod's
|
||||
-- battle row is on, and so the one place a regression can sit unseen. It sat
|
||||
-- there: the pic tint was a multiply that could only darken, and the arrival
|
||||
-- sparkle was armed from Stadium.update and therefore never played here.
|
||||
--
|
||||
-- Each rung is shot TWICE, shiny and ordinary, from the same species at the
|
||||
-- same spot, as a STRIP -- the intro flashes the pic through palette variants
|
||||
-- on its way in, so a single frame lands wherever the pacing put it.
|
||||
--
|
||||
-- Reports, per rung: whether the roll landed, the PALETTE the pic was baked
|
||||
-- under (which is where the recolour now lives), and whether the flat-path
|
||||
-- sparkle armed and drew.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
|
||||
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 40
|
||||
local DIR = os.getenv("DS_SHOTS") or ".claude/shiny_update/flat"
|
||||
|
||||
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 ShinyPics = lib.require("ShinyPics")
|
||||
local ShinyFlash = lib.require("ShinyFlash")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
|
||||
U.log(("wraps: pics=%s flash=%s"):format(
|
||||
tostring(PaletteFX.dramaticShapeShiny == true),
|
||||
tostring(ShinyFlash.installed == true)))
|
||||
|
||||
-- ------- what the palette wrap hands the image cache
|
||||
--
|
||||
-- The recolour is baked ONCE, at build time, so counting draws says nothing
|
||||
-- about it. What matters is the cache key and the colours behind it: ask
|
||||
-- PaletteFX the same two questions monPalette asks, with the note the
|
||||
-- sprite hook would have left a moment earlier.
|
||||
local function palReport(mon)
|
||||
ShinyPics.note({ kind = "battle", species = SPECIES, mon = mon,
|
||||
data = game.data })
|
||||
local cols = PaletteFX.monPal(game.data, SPECIES)
|
||||
local name = PaletteFX.monPalName(game.data, SPECIES)
|
||||
local out = { "pal=" .. tostring(name) }
|
||||
for i = 1, math.min(3, cols and #cols or 0) do
|
||||
local c = cols[i]
|
||||
if type(c) == "table" and c[1] then
|
||||
out[#out + 1] = ("c%d=%d,%d,%d"):format(i, c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- The party is built at ORDINARY odds and pinned afterwards, so the
|
||||
-- player's own Pikachu stays common: this run is about the foe.
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
local function leave()
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local function shoot(rung, label, shiny)
|
||||
OverworldBattle.setting:setValue(rung, game)
|
||||
Shiny.setOdds(shiny and 1 or 100000000)
|
||||
for k in pairs(ShinyFlash.debug) do ShinyFlash.debug[k] = 0 end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
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: 3D-BTL=%s isShiny=%s %s"):format(
|
||||
label, shiny and "shiny" or "normal",
|
||||
tostring(OverworldBattle.setting:get()), tostring(Shiny.isShiny(mon)),
|
||||
palReport(mon)))
|
||||
|
||||
-- The WIPE has to be walked through first. A driver run with no input at
|
||||
-- all sits on BattleTransition forever -- the battle is never pushed, so
|
||||
-- nothing about it draws and every counter below reads zero, which is
|
||||
-- exactly the false negative this probe produced before the taps went in.
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(10) end
|
||||
|
||||
-- the sparkle is three quarters of a second long and starts on the frame
|
||||
-- the pic appears, so the strip is TIGHT
|
||||
for k = 1, 10 do
|
||||
U.shot(game, ("%s/%s_%s_%02d.png"):format(DIR, label,
|
||||
shiny and "shiny" or "normal",
|
||||
k))
|
||||
U.wait(9)
|
||||
end
|
||||
local d = ShinyFlash.debug
|
||||
U.log((" flash: renders=%s armed=%d draws=%d sparks=%d follows=%d occ=%d %s")
|
||||
:format(tostring(d.renders), d.armed, d.draws, d.sparks,
|
||||
d.follows, d.occupied, tostring(d.err)))
|
||||
leave()
|
||||
end
|
||||
|
||||
shoot(false, "off", false)
|
||||
shoot(false, "off", true)
|
||||
shoot(true, "cards", false)
|
||||
shoot(true, "cards", true)
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,643 @@
|
||||
-- generated fixture: {dex, srcR,srcG,srcB, wantR,wantG,wantB}
|
||||
return {
|
||||
{6,222,131,123,160,165,185},
|
||||
{6,189,115,41,105,96,134},
|
||||
{6,16,16,8,12,11,13},
|
||||
{6,115,57,24,60,58,81},
|
||||
{6,230,139,57,131,122,165},
|
||||
{6,213,164,74,139,126,161},
|
||||
{6,41,32,24,31,30,35},
|
||||
{6,8,49,57,39,37,26},
|
||||
{6,82,41,8,39,36,54},
|
||||
{6,246,172,74,152,138,182},
|
||||
{6,172,98,32,92,84,120},
|
||||
{6,65,49,24,43,39,50},
|
||||
{6,213,131,123,157,161,179},
|
||||
{6,156,156,148,152,151,153},
|
||||
{6,255,246,156,209,193,218},
|
||||
{6,205,164,74,137,123,156},
|
||||
{6,24,90,90,65,61,49},
|
||||
{6,172,98,24,88,80,116},
|
||||
{6,123,24,24,61,68,86},
|
||||
{6,16,90,82,62,55,44},
|
||||
{6,230,148,65,137,127,168},
|
||||
{6,139,74,24,72,67,96},
|
||||
{6,156,90,41,89,84,113},
|
||||
{6,123,131,131,128,127,126},
|
||||
{6,246,164,90,157,148,188},
|
||||
{6,222,139,57,128,119,160},
|
||||
{6,98,57,16,52,47,67},
|
||||
{6,32,131,131,94,87,69},
|
||||
{6,148,90,32,82,76,105},
|
||||
{6,197,123,115,146,149,166},
|
||||
{6,222,106,106,150,157,178},
|
||||
{6,238,222,106,176,156,188},
|
||||
{6,32,41,32,38,35,36},
|
||||
{6,32,90,90,68,64,54},
|
||||
{6,32,49,49,43,41,38},
|
||||
{6,82,16,16,41,45,57},
|
||||
{6,189,189,189,189,189,189},
|
||||
{6,148,90,41,86,81,108},
|
||||
{6,8,16,24,17,18,14},
|
||||
{6,90,98,139,114,121,108},
|
||||
{42,123,41,115,16,15,26},
|
||||
{42,90,131,148,26,33,28},
|
||||
{42,90,156,189,27,42,32},
|
||||
{42,57,8,57,5,5,11},
|
||||
{42,123,106,197,30,44,46},
|
||||
{42,115,148,222,29,55,46},
|
||||
{42,98,57,98,17,17,22},
|
||||
{42,49,65,74,14,17,15},
|
||||
{42,197,189,205,46,50,53},
|
||||
{42,32,164,230,20,46,27},
|
||||
{42,41,205,246,19,52,24},
|
||||
{42,74,131,164,24,35,28},
|
||||
{42,90,98,189,27,42,40},
|
||||
{42,41,156,197,20,40,24},
|
||||
{42,98,90,180,27,40,40},
|
||||
{42,24,115,164,15,32,20},
|
||||
{42,164,189,205,39,53,44},
|
||||
{42,164,106,180,30,33,42},
|
||||
{42,74,148,213,25,47,34},
|
||||
{42,82,139,156,25,34,27},
|
||||
{42,90,180,230,25,55,34},
|
||||
{42,74,115,139,23,31,25},
|
||||
{42,49,189,230,21,49,26},
|
||||
{42,65,82,98,18,22,20},
|
||||
{42,172,115,189,31,35,45},
|
||||
{42,49,123,189,21,39,28},
|
||||
{42,172,197,197,42,50,42},
|
||||
{42,189,74,164,28,25,41},
|
||||
{42,65,57,123,18,26,27},
|
||||
{42,49,16,49,6,6,10},
|
||||
{42,32,57,74,11,16,12},
|
||||
{42,65,156,222,23,49,33},
|
||||
{42,106,98,189,29,43,43},
|
||||
{42,222,213,222,51,51,58},
|
||||
{42,172,197,205,41,53,44},
|
||||
{42,238,238,238,60,60,60},
|
||||
{42,189,197,205,46,53,49},
|
||||
{42,41,8,49,5,6,10},
|
||||
{42,49,0,49,3,3,9},
|
||||
{42,205,213,213,50,55,50},
|
||||
{79,139,82,57,28,18,31},
|
||||
{79,90,74,57,21,16,21},
|
||||
{79,246,106,74,48,17,63},
|
||||
{79,222,131,123,46,27,59},
|
||||
{79,156,131,82,36,24,34},
|
||||
{79,213,90,90,38,24,52},
|
||||
{79,222,197,148,62,31,57},
|
||||
{79,230,205,148,66,29,58},
|
||||
{79,213,189,156,58,34,56},
|
||||
{79,197,189,139,51,33,44},
|
||||
{79,180,106,90,37,26,42},
|
||||
{79,230,115,115,43,24,62},
|
||||
{79,148,82,82,29,24,34},
|
||||
{79,246,156,156,50,24,76},
|
||||
{79,24,8,8,4,3,5},
|
||||
{79,131,65,41,25,14,29},
|
||||
{79,213,98,82,40,23,51},
|
||||
{79,49,32,8,10,4,10},
|
||||
{79,123,74,49,25,16,27},
|
||||
{79,57,32,24,11,8,13},
|
||||
{79,180,57,65,28,20,39},
|
||||
{79,164,82,57,32,19,36},
|
||||
{79,148,106,82,32,24,34},
|
||||
{79,230,123,106,47,23,61},
|
||||
{79,238,115,98,47,21,63},
|
||||
{79,246,115,98,49,19,67},
|
||||
{79,238,139,131,49,24,68},
|
||||
{79,139,82,49,29,16,31},
|
||||
{79,246,131,106,53,20,68},
|
||||
{79,197,98,74,38,23,45},
|
||||
{79,213,106,98,41,25,53},
|
||||
{79,172,98,90,34,26,40},
|
||||
{79,197,98,82,38,24,46},
|
||||
{79,123,57,32,23,12,26},
|
||||
{79,189,189,180,48,44,46},
|
||||
{79,213,213,213,53,53,53},
|
||||
{79,115,106,82,27,22,26},
|
||||
{79,74,32,32,13,10,17},
|
||||
{79,148,74,57,28,19,33},
|
||||
{79,238,246,238,72,60,49},
|
||||
{146,172,131,0,231,152,197},
|
||||
{146,205,82,0,234,162,229},
|
||||
{146,57,57,156,183,215,205},
|
||||
{146,106,24,0,216,132,227},
|
||||
{146,24,24,32,162,177,172},
|
||||
{146,74,65,123,183,206,202},
|
||||
{146,213,172,82,230,199,219},
|
||||
{146,205,90,16,230,172,226},
|
||||
{146,246,197,8,235,179,209},
|
||||
{146,41,24,82,153,205,203},
|
||||
{146,255,230,115,245,212,229},
|
||||
{146,246,213,41,237,189,213},
|
||||
{146,74,90,131,188,208,196},
|
||||
{146,189,189,189,230,230,230},
|
||||
{146,115,74,0,227,135,199},
|
||||
{146,246,123,0,236,175,226},
|
||||
{146,197,131,8,231,165,210},
|
||||
{146,180,123,0,232,154,205},
|
||||
{146,106,115,65,204,182,186},
|
||||
{146,98,32,0,225,129,226},
|
||||
{146,255,180,0,237,177,215},
|
||||
{146,205,156,16,230,172,206},
|
||||
{146,197,156,74,225,196,215},
|
||||
{146,74,57,148,182,214,209},
|
||||
{146,255,123,0,237,177,228},
|
||||
{146,115,57,0,227,135,212},
|
||||
{146,246,255,222,253,245,245},
|
||||
{146,106,65,0,227,132,200},
|
||||
{146,98,16,0,210,129,226},
|
||||
{146,230,189,16,232,179,207},
|
||||
{146,255,230,41,240,190,212},
|
||||
{146,255,246,32,239,187,207},
|
||||
{146,213,156,41,227,187,214},
|
||||
{146,222,123,0,235,167,220},
|
||||
{146,164,98,0,231,150,209},
|
||||
{146,148,49,8,223,151,226},
|
||||
{146,90,65,0,225,127,187},
|
||||
{146,255,213,16,238,182,211},
|
||||
{146,24,32,49,156,191,168},
|
||||
{146,255,180,16,238,182,218},
|
||||
{96,131,82,57,175,159,190},
|
||||
{96,32,32,8,173,102,161},
|
||||
{96,197,156,0,213,138,216},
|
||||
{96,148,115,41,191,152,197},
|
||||
{96,57,57,41,160,144,158},
|
||||
{96,131,115,8,203,121,200},
|
||||
{96,65,57,16,180,115,180},
|
||||
{96,148,57,57,167,162,196},
|
||||
{96,131,32,49,142,142,195},
|
||||
{96,139,98,32,185,144,197},
|
||||
{96,123,82,49,175,152,189},
|
||||
{96,213,213,213,234,234,234},
|
||||
{96,139,90,65,179,165,192},
|
||||
{96,131,90,57,178,159,190},
|
||||
{96,164,148,98,200,185,201},
|
||||
{96,139,98,8,193,124,205},
|
||||
{96,106,98,49,183,150,182},
|
||||
{96,115,90,24,186,132,193},
|
||||
{96,115,98,32,188,139,190},
|
||||
{96,57,49,24,166,126,170},
|
||||
{96,172,131,8,204,135,210},
|
||||
{96,65,57,49,158,149,163},
|
||||
{96,106,98,82,178,169,180},
|
||||
{96,49,49,32,159,136,155},
|
||||
{96,180,139,8,206,138,211},
|
||||
{96,131,123,90,189,176,189},
|
||||
{96,156,148,123,201,193,201},
|
||||
{96,106,90,8,199,113,198},
|
||||
{96,148,115,24,195,140,201},
|
||||
{96,115,115,123,186,188,186},
|
||||
{96,106,65,57,164,155,181},
|
||||
{96,255,197,0,219,159,223},
|
||||
{96,230,222,205,238,233,239},
|
||||
{96,115,98,57,181,156,185},
|
||||
{96,180,172,131,211,199,211},
|
||||
{96,148,123,57,192,162,196},
|
||||
{96,230,180,0,216,150,220},
|
||||
{96,156,115,0,202,122,211},
|
||||
{96,172,123,32,195,152,205},
|
||||
{96,123,82,57,174,157,188},
|
||||
{140,49,49,49,101,101,101},
|
||||
{140,255,8,131,174,155,151},
|
||||
{140,139,82,32,135,138,118},
|
||||
{140,123,0,8,124,113,96},
|
||||
{140,230,24,24,169,162,149},
|
||||
{140,32,24,8,81,85,73},
|
||||
{140,230,156,156,212,210,205},
|
||||
{140,82,0,0,106,98,83},
|
||||
{140,131,82,24,128,132,111},
|
||||
{140,74,0,0,103,95,80},
|
||||
{140,222,172,172,214,212,209},
|
||||
{140,82,74,82,123,121,122},
|
||||
{140,197,106,106,182,179,173},
|
||||
{140,246,49,74,184,175,165},
|
||||
{140,255,16,123,177,159,154},
|
||||
{140,156,24,57,143,129,120},
|
||||
{140,156,82,8,135,139,111},
|
||||
{140,255,131,156,214,208,203},
|
||||
{140,222,213,205,224,225,223},
|
||||
{140,82,49,16,106,109,92},
|
||||
{140,205,172,172,207,206,204},
|
||||
{140,230,172,172,217,215,212},
|
||||
{140,230,82,115,188,180,174},
|
||||
{140,255,82,189,198,183,182},
|
||||
{140,222,156,90,185,187,175},
|
||||
{140,180,98,16,145,150,125},
|
||||
{140,238,115,123,202,197,190},
|
||||
{140,65,8,16,100,92,82},
|
||||
{140,172,74,82,161,157,151},
|
||||
{140,255,172,197,228,223,220},
|
||||
{140,197,57,65,166,160,152},
|
||||
{140,205,131,49,164,166,152},
|
||||
{140,57,57,57,106,106,106},
|
||||
{140,131,32,49,134,125,115},
|
||||
{140,57,32,32,101,98,94},
|
||||
{140,123,8,41,125,110,100},
|
||||
{140,246,32,82,178,167,158},
|
||||
{140,164,98,32,144,147,127},
|
||||
{140,255,0,90,171,155,147},
|
||||
{140,255,106,172,206,195,192},
|
||||
{23,139,106,16,46,41,75},
|
||||
{23,172,131,24,58,53,94},
|
||||
{23,106,8,24,29,50,57},
|
||||
{23,246,197,49,85,73,149},
|
||||
{23,255,213,41,88,69,153},
|
||||
{23,238,180,16,74,64,126},
|
||||
{23,222,123,131,101,139,158},
|
||||
{23,246,213,82,99,81,165},
|
||||
{23,156,106,172,93,115,97},
|
||||
{23,90,16,24,29,44,50},
|
||||
{23,164,90,98,85,99,106},
|
||||
{23,213,164,41,77,71,119},
|
||||
{23,115,74,98,65,77,75},
|
||||
{23,230,189,106,98,92,160},
|
||||
{23,115,65,123,62,79,67},
|
||||
{23,222,172,74,87,82,140},
|
||||
{23,213,172,90,93,88,139},
|
||||
{23,255,213,57,91,73,161},
|
||||
{23,156,115,164,96,113,101},
|
||||
{23,222,156,8,63,56,116},
|
||||
{23,123,82,123,71,83,76},
|
||||
{23,98,49,115,52,71,55},
|
||||
{23,106,74,65,58,63,70},
|
||||
{23,180,180,180,135,135,135},
|
||||
{23,115,49,90,52,71,67},
|
||||
{23,98,57,90,52,64,59},
|
||||
{23,180,123,180,102,125,112},
|
||||
{23,90,65,41,42,44,56},
|
||||
{23,238,172,24,73,66,130},
|
||||
{23,222,164,8,65,56,116},
|
||||
{23,131,106,41,55,52,77},
|
||||
{23,180,139,164,110,129,126},
|
||||
{23,32,8,24,12,18,17},
|
||||
{23,213,164,24,71,62,115},
|
||||
{23,131,74,148,73,94,77},
|
||||
{23,65,0,16,15,30,34},
|
||||
{23,139,106,49,59,58,83},
|
||||
{23,189,139,24,62,57,103},
|
||||
{23,131,65,74,64,78,83},
|
||||
{23,65,49,0,18,15,34},
|
||||
{114,205,197,197,197,197,205},
|
||||
{114,180,180,189,180,189,180},
|
||||
{114,123,123,230,123,230,123},
|
||||
{114,205,205,255,205,255,205},
|
||||
{114,189,49,74,49,74,189},
|
||||
{114,222,213,222,213,222,222},
|
||||
{114,82,82,205,82,205,82},
|
||||
{114,131,131,238,131,238,131},
|
||||
{114,82,82,164,82,164,82},
|
||||
{114,230,230,255,230,255,230},
|
||||
{114,41,41,32,41,32,41},
|
||||
{114,148,32,57,32,57,148},
|
||||
{114,65,65,90,65,90,65},
|
||||
{114,172,172,180,172,180,172},
|
||||
{114,123,123,213,123,213,123},
|
||||
{114,238,230,238,230,238,238},
|
||||
{114,41,41,49,41,49,41},
|
||||
{114,238,238,238,238,238,238},
|
||||
{114,205,197,205,197,205,205},
|
||||
{114,230,230,222,230,222,230},
|
||||
{114,90,90,90,90,90,90},
|
||||
{114,255,255,238,255,238,255},
|
||||
{114,106,106,238,106,238,106},
|
||||
{114,246,106,131,106,131,246},
|
||||
{114,49,49,123,49,123,49},
|
||||
{114,16,16,41,16,41,16},
|
||||
{114,255,255,246,255,246,255},
|
||||
{114,8,8,16,8,16,8},
|
||||
{114,205,82,106,82,106,205},
|
||||
{114,205,205,213,205,213,205},
|
||||
{114,32,32,24,32,24,32},
|
||||
{114,41,49,106,49,106,41},
|
||||
{114,41,41,98,41,98,41},
|
||||
{114,123,123,148,123,148,123},
|
||||
{114,156,148,205,148,205,156},
|
||||
{114,156,41,57,41,57,156},
|
||||
{114,222,90,106,90,106,222},
|
||||
{114,57,57,65,57,65,57},
|
||||
{114,123,123,205,123,205,123},
|
||||
{114,82,74,82,74,82,82},
|
||||
{134,197,205,213,212,197,213},
|
||||
{134,205,222,230,224,205,230},
|
||||
{134,213,222,238,238,213,236},
|
||||
{134,32,90,90,58,32,90},
|
||||
{134,164,189,189,175,164,189},
|
||||
{134,65,90,131,131,65,126},
|
||||
{134,32,139,172,128,32,172},
|
||||
{134,156,172,189,188,156,189},
|
||||
{134,255,246,148,148,255,187},
|
||||
{134,0,8,16,15,0,16},
|
||||
{134,24,41,82,82,24,73},
|
||||
{134,57,148,180,144,57,180},
|
||||
{134,123,197,197,156,123,197},
|
||||
{134,148,197,205,182,148,205},
|
||||
{134,24,8,0,5,24,0},
|
||||
{134,65,90,148,148,65,136},
|
||||
{134,164,213,222,199,164,222},
|
||||
{134,41,74,123,123,41,119},
|
||||
{134,8,16,65,65,8,47},
|
||||
{134,213,222,156,156,222,195},
|
||||
{134,74,57,32,32,74,34},
|
||||
{134,65,106,148,144,65,148},
|
||||
{134,24,41,98,98,24,82},
|
||||
{134,115,123,123,119,115,123},
|
||||
{134,49,90,98,79,49,98},
|
||||
{134,131,172,197,186,131,197},
|
||||
{134,0,24,16,3,0,24},
|
||||
{134,32,82,98,78,32,98},
|
||||
{134,156,180,205,203,156,205},
|
||||
{134,148,213,222,190,148,222},
|
||||
{134,139,197,205,177,139,205},
|
||||
{134,32,49,98,98,32,85},
|
||||
{134,82,98,148,148,82,134},
|
||||
{134,41,49,115,115,41,90},
|
||||
{134,222,230,230,226,222,230},
|
||||
{134,106,106,115,115,106,111},
|
||||
{134,32,57,106,106,32,98},
|
||||
{134,32,106,148,126,32,148},
|
||||
{134,32,90,131,118,32,131},
|
||||
{134,41,90,139,134,41,139},
|
||||
{143,82,106,90,105,123,120},
|
||||
{143,148,115,98,154,149,125},
|
||||
{143,230,222,213,230,230,221},
|
||||
{143,139,65,65,149,121,94},
|
||||
{143,16,0,8,63,15,15},
|
||||
{143,213,180,123,197,203,154},
|
||||
{143,189,90,90,181,154,127},
|
||||
{143,115,57,41,130,113,70},
|
||||
{143,57,57,57,82,82,82},
|
||||
{143,172,139,90,165,169,124},
|
||||
{143,189,156,123,186,186,150},
|
||||
{143,222,222,222,226,226,226},
|
||||
{143,255,230,197,243,245,214},
|
||||
{143,230,230,230,233,233,233},
|
||||
{143,197,197,180,197,201,192},
|
||||
{143,222,205,123,192,210,156},
|
||||
{143,238,230,180,219,231,199},
|
||||
{143,90,90,65,100,110,90},
|
||||
{143,213,197,123,188,203,154},
|
||||
{143,205,197,123,180,198,153},
|
||||
{143,90,82,57,103,110,83},
|
||||
{143,205,180,139,196,200,164},
|
||||
{143,213,213,189,208,214,201},
|
||||
{143,115,65,65,130,111,91},
|
||||
{143,164,131,74,157,164,108},
|
||||
{143,213,197,156,202,209,178},
|
||||
{143,123,106,65,127,136,92},
|
||||
{143,180,148,148,184,175,167},
|
||||
{143,205,197,156,194,203,176},
|
||||
{143,123,82,74,136,124,100},
|
||||
{143,24,8,8,60,46,32},
|
||||
{143,255,238,189,235,244,208},
|
||||
{143,230,222,205,227,229,215},
|
||||
{143,213,180,131,200,205,160},
|
||||
{143,131,65,65,142,118,93},
|
||||
{143,222,189,139,208,213,167},
|
||||
{143,24,16,8,60,60,32},
|
||||
{143,205,189,156,199,203,176},
|
||||
{143,156,148,98,147,159,127},
|
||||
{143,164,131,98,165,165,128},
|
||||
{95,57,49,65,34,41,31},
|
||||
{95,0,197,230,144,42,0},
|
||||
{95,197,197,238,181,201,71},
|
||||
{95,0,16,65,41,37,0},
|
||||
{95,148,156,156,99,92,91},
|
||||
{95,238,238,230,132,126,167},
|
||||
{95,98,98,98,61,61,61},
|
||||
{95,148,148,156,98,99,91},
|
||||
{95,16,32,156,94,98,10},
|
||||
{95,0,49,106,66,46,0},
|
||||
{95,0,123,197,123,65,0},
|
||||
{95,0,180,238,149,59,0},
|
||||
{95,0,8,16,10,7,0},
|
||||
{95,139,148,164,106,101,83},
|
||||
{95,65,74,82,51,47,41},
|
||||
{95,189,197,197,128,115,113},
|
||||
{95,0,90,197,123,85,0},
|
||||
{95,106,115,131,82,79,66},
|
||||
{95,0,139,205,128,60,0},
|
||||
{95,131,123,148,85,94,76},
|
||||
{95,0,24,49,31,20,0},
|
||||
{95,0,8,98,57,61,0},
|
||||
{95,98,98,115,70,72,61},
|
||||
{95,139,139,139,87,87,87},
|
||||
{95,49,49,57,35,36,31},
|
||||
{95,0,74,131,82,48,0},
|
||||
{95,115,106,131,74,82,66},
|
||||
{95,0,115,197,123,70,0},
|
||||
{95,156,148,180,106,121,84},
|
||||
{95,123,123,139,86,87,77},
|
||||
{95,0,41,156,98,86,0},
|
||||
{95,0,106,172,108,57,0},
|
||||
{95,16,57,156,98,85,10},
|
||||
{95,0,115,148,93,35,0},
|
||||
{95,205,205,230,164,176,96},
|
||||
{95,131,139,156,100,96,80},
|
||||
{95,189,197,205,140,128,106},
|
||||
{95,98,106,131,82,80,61},
|
||||
{95,213,213,246,194,216,71},
|
||||
{95,24,32,32,20,16,15},
|
||||
{35,180,74,65,180,65,123},
|
||||
{35,197,82,82,197,82,140},
|
||||
{35,172,131,131,172,131,152},
|
||||
{35,65,41,41,65,41,53},
|
||||
{35,213,230,230,213,230,230},
|
||||
{35,172,74,65,172,65,119},
|
||||
{35,148,164,164,148,164,164},
|
||||
{35,148,106,98,148,98,123},
|
||||
{35,90,65,57,90,57,74},
|
||||
{35,57,57,156,57,57,156},
|
||||
{35,222,164,164,222,164,193},
|
||||
{35,255,222,222,255,222,238},
|
||||
{35,8,8,0,1,8,0},
|
||||
{35,148,148,156,148,148,156},
|
||||
{35,49,41,16,15,52,10},
|
||||
{35,222,246,238,222,246,238},
|
||||
{35,148,82,90,148,82,115},
|
||||
{35,57,8,8,57,8,33},
|
||||
{35,131,131,131,131,131,131},
|
||||
{35,255,189,197,255,189,222},
|
||||
{35,205,90,82,205,82,144},
|
||||
{35,230,197,197,230,197,214},
|
||||
{35,238,139,139,238,139,189},
|
||||
{35,180,115,115,180,115,148},
|
||||
{35,65,57,57,65,57,57},
|
||||
{35,172,139,131,172,131,152},
|
||||
{35,131,32,24,131,24,78},
|
||||
{35,65,49,8,9,69,0},
|
||||
{35,41,24,8,8,44,2},
|
||||
{35,180,139,123,180,123,152},
|
||||
{35,98,123,123,98,123,123},
|
||||
{35,41,41,115,41,41,115},
|
||||
{35,106,16,16,106,16,61},
|
||||
{35,106,131,131,106,131,131},
|
||||
{35,16,16,24,16,16,24},
|
||||
{35,156,139,139,156,139,139},
|
||||
{35,123,32,24,123,24,74},
|
||||
{35,49,24,24,49,24,37},
|
||||
{35,16,16,41,16,16,41},
|
||||
{35,189,164,172,189,164,176},
|
||||
{36,230,148,172,230,148,189},
|
||||
{36,255,148,164,255,148,202},
|
||||
{36,156,123,123,156,123,123},
|
||||
{36,189,156,148,189,148,169},
|
||||
{36,98,41,24,98,24,61},
|
||||
{36,74,0,0,74,0,37},
|
||||
{36,148,82,74,148,74,111},
|
||||
{36,65,49,49,65,49,49},
|
||||
{36,238,197,180,238,180,209},
|
||||
{36,230,180,172,230,172,201},
|
||||
{36,24,16,16,24,16,20},
|
||||
{36,246,148,164,246,148,197},
|
||||
{36,180,156,156,180,156,156},
|
||||
{36,49,49,49,49,49,49},
|
||||
{36,222,189,189,222,189,206},
|
||||
{36,98,90,90,98,90,90},
|
||||
{36,148,131,123,148,131,123},
|
||||
{36,222,222,222,222,222,222},
|
||||
{36,222,189,180,222,180,201},
|
||||
{36,213,156,156,213,156,185},
|
||||
{36,222,180,180,222,180,201},
|
||||
{36,197,123,115,197,115,156},
|
||||
{36,230,139,156,230,139,185},
|
||||
{36,115,90,90,115,90,90},
|
||||
{36,32,8,8,32,8,20},
|
||||
{36,65,49,41,65,41,53},
|
||||
{36,8,0,0,8,0,4},
|
||||
{36,98,32,32,98,32,65},
|
||||
{36,164,98,98,164,98,131},
|
||||
{36,106,98,90,106,98,90},
|
||||
{36,230,205,197,230,197,214},
|
||||
{36,246,213,205,246,205,226},
|
||||
{36,164,148,148,164,148,148},
|
||||
{36,238,164,189,238,164,201},
|
||||
{36,156,131,123,156,131,123},
|
||||
{36,230,172,180,230,172,201},
|
||||
{36,24,8,8,24,8,16},
|
||||
{36,106,90,90,106,90,90},
|
||||
{36,213,197,189,213,189,201},
|
||||
{36,246,172,156,246,156,201},
|
||||
{39,255,189,197,250,194,250},
|
||||
{39,222,255,255,240,255,222},
|
||||
{39,139,131,139,139,131,139},
|
||||
{39,74,148,139,115,154,68},
|
||||
{39,222,246,246,235,248,220},
|
||||
{39,131,156,148,131,156,148},
|
||||
{39,41,82,82,64,85,38},
|
||||
{39,189,90,98,182,97,182},
|
||||
{39,180,139,148,177,142,177},
|
||||
{39,205,123,131,199,129,199},
|
||||
{39,180,148,156,178,150,178},
|
||||
{39,74,164,148,124,171,67},
|
||||
{39,230,230,238,234,239,229},
|
||||
{39,106,197,189,157,204,99},
|
||||
{39,74,90,90,74,90,90},
|
||||
{39,230,139,148,223,146,223},
|
||||
{39,41,98,82,73,102,37},
|
||||
{39,246,156,164,239,163,239},
|
||||
{39,180,197,197,180,197,197},
|
||||
{39,139,90,106,135,94,135},
|
||||
{39,32,74,74,55,77,29},
|
||||
{39,131,82,90,127,86,127},
|
||||
{39,32,106,90,73,112,26},
|
||||
{39,164,189,189,178,191,162},
|
||||
{39,41,115,98,82,121,35},
|
||||
{39,246,172,172,240,178,240},
|
||||
{39,131,205,197,172,211,125},
|
||||
{39,197,106,106,190,113,190},
|
||||
{39,98,65,74,96,67,96},
|
||||
{39,205,246,246,228,249,202},
|
||||
{39,98,172,156,139,178,92},
|
||||
{39,106,180,172,147,186,100},
|
||||
{39,24,57,57,42,59,22},
|
||||
{39,172,197,197,186,199,170},
|
||||
{39,115,139,148,115,139,148},
|
||||
{39,32,106,98,73,112,26},
|
||||
{39,230,180,197,226,184,226},
|
||||
{39,57,90,82,75,92,55},
|
||||
{39,74,148,131,115,154,68},
|
||||
{39,189,238,238,216,242,185},
|
||||
{40,164,123,131,161,126,161},
|
||||
{40,139,131,131,139,131,131},
|
||||
{40,156,139,139,156,139,139},
|
||||
{40,222,164,180,218,168,218},
|
||||
{40,222,205,205,221,206,221},
|
||||
{40,255,238,246,254,239,254},
|
||||
{40,230,164,164,225,169,225},
|
||||
{40,131,131,131,131,131,131},
|
||||
{40,197,115,123,191,121,191},
|
||||
{40,82,106,106,82,106,106},
|
||||
{40,230,172,189,226,176,226},
|
||||
{40,148,156,164,148,156,164},
|
||||
{40,74,90,90,74,90,90},
|
||||
{40,82,156,139,123,162,76},
|
||||
{40,164,115,131,160,119,160},
|
||||
{40,82,49,65,80,51,80},
|
||||
{40,65,106,106,88,109,62},
|
||||
{40,74,98,98,74,98,98},
|
||||
{40,189,106,106,183,112,183},
|
||||
{40,222,246,246,235,248,220},
|
||||
{40,222,197,205,220,199,220},
|
||||
{40,115,139,148,115,139,148},
|
||||
{40,238,246,246,242,247,237},
|
||||
{40,74,131,123,106,135,70},
|
||||
{40,156,115,131,153,118,153},
|
||||
{40,82,115,115,100,117,80},
|
||||
{40,230,148,148,224,154,224},
|
||||
{40,148,213,205,184,218,143},
|
||||
{40,246,189,197,242,193,242},
|
||||
{40,139,156,164,139,156,164},
|
||||
{40,172,189,197,186,199,170},
|
||||
{40,230,172,180,226,176,226},
|
||||
{40,164,131,139,162,133,162},
|
||||
{40,205,148,156,201,152,201},
|
||||
{40,238,246,238,238,246,238},
|
||||
{40,156,115,123,153,118,153},
|
||||
{40,156,106,123,152,110,152},
|
||||
{40,197,131,131,192,136,192},
|
||||
{40,255,164,164,248,171,248},
|
||||
{40,131,172,164,154,175,128},
|
||||
{130,123,197,230,220,80,70},
|
||||
{130,230,222,180,230,222,180},
|
||||
{130,255,222,131,255,222,131},
|
||||
{130,57,131,180,148,53,47},
|
||||
{130,106,82,41,106,82,41},
|
||||
{130,98,180,238,232,56,43},
|
||||
{130,65,90,98,80,55,53},
|
||||
{130,189,172,123,189,172,123},
|
||||
{130,65,123,148,121,58,53},
|
||||
{130,221,221,221,221,221,221},
|
||||
{130,57,82,106,87,49,47},
|
||||
{130,222,222,222,222,222,222},
|
||||
{130,189,164,106,189,164,106},
|
||||
{130,85,85,85,85,85,85},
|
||||
{130,172,148,98,172,148,98},
|
||||
{130,115,189,230,221,73,62},
|
||||
{130,189,164,74,189,164,74},
|
||||
{130,230,246,246,223,171,167},
|
||||
{130,205,180,90,205,180,90},
|
||||
{130,82,57,16,82,57,16},
|
||||
{130,238,90,106,238,90,106},
|
||||
{130,156,180,164,156,180,164},
|
||||
{130,82,98,106,82,98,106},
|
||||
{130,164,230,246,239,106,97},
|
||||
{130,133,133,133,133,133,133},
|
||||
{130,156,131,57,156,131,57},
|
||||
{130,90,49,106,87,43,40},
|
||||
{130,98,115,98,98,115,98},
|
||||
{130,98,98,65,98,98,65},
|
||||
{130,148,49,65,148,49,65},
|
||||
{130,156,156,156,156,156,156},
|
||||
{130,49,49,82,67,42,40},
|
||||
{130,230,222,230,230,222,230},
|
||||
{130,153,153,153,153,153,153},
|
||||
{130,255,230,255,255,150,143},
|
||||
{130,8,82,131,107,13,7},
|
||||
{130,139,213,255,255,81,68},
|
||||
{130,32,16,32,26,14,13},
|
||||
{130,164,57,65,164,57,65},
|
||||
{130,82,82,32,82,82,32},
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,12 +1,17 @@
|
||||
-- The Lua ROM extractor, against the Python packer that is its oracle.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR]
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR] [--mod=DIR]
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Defaults: the ROM under
|
||||
-- model_extract/baseroms/, the oracle in assets/stadium (whatever
|
||||
-- tools/stadium_pack.py last wrote there).
|
||||
--
|
||||
-- --mod points at the mod directory whose lib/ is under test. It exists for
|
||||
-- worktrees: the ROM and the packs are both gitignored, so a worktree has
|
||||
-- neither, and without this the test would load the SHARED checkout's
|
||||
-- modules while claiming to test the branch's.
|
||||
--
|
||||
-- ------- what this is for
|
||||
--
|
||||
-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of
|
||||
@@ -30,7 +35,7 @@ for _, a in ipairs({ ... }) do
|
||||
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
|
||||
end
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
|
||||
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
|
||||
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
|
||||
|
||||
@@ -46,6 +51,9 @@ function V.require(name)
|
||||
return loaded[name]
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
-- the mod's own directory, so a module that loads a data file finds it
|
||||
-- relative to the MOD rather than to wherever this was run from
|
||||
V.path = MOD
|
||||
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumBuild = V.require("StadiumBuild")
|
||||
@@ -79,6 +87,7 @@ if args.only then
|
||||
end
|
||||
|
||||
local checked, matched, missing, failed = 0, 0, 0, 0
|
||||
local shinyOk, shinyMissing, shinyBad = 0, 0, 0
|
||||
local firstBad = nil
|
||||
local t0 = os.clock()
|
||||
|
||||
@@ -93,6 +102,28 @@ for fileno = 0, StadiumRom.N_POKEMON - 1 do
|
||||
if args.out then
|
||||
local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb")
|
||||
if fp then fp:write(res.bytes) fp:close() end
|
||||
-- the shiny variant too, so the pair can be diffed out of process
|
||||
if res.shinyBytes then
|
||||
local sp = io.open(("%s/%03ds.dsm"):format(args.out, res.species), "wb")
|
||||
if sp then sp:write(res.shinyBytes) sp:close() end
|
||||
end
|
||||
end
|
||||
-- The shiny pack is the same DSM3 with recoloured texels, so it must be
|
||||
-- exactly as long and must actually differ. A species that produced none
|
||||
-- is counted rather than failed: it ships without a recolour and the
|
||||
-- runtime falls back to its normal model.
|
||||
if not res.shinyBytes then
|
||||
shinyMissing = shinyMissing + 1
|
||||
elseif #res.shinyBytes ~= #res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is %d bytes, normal is %d\n")
|
||||
:format(res.species, #res.shinyBytes, #res.bytes))
|
||||
elseif res.shinyBytes == res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is identical to normal\n")
|
||||
:format(res.species))
|
||||
else
|
||||
shinyOk = shinyOk + 1
|
||||
end
|
||||
local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species))
|
||||
if not want then
|
||||
@@ -119,8 +150,24 @@ io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, "
|
||||
.. "%d extractions failed (%.1fs)\n")
|
||||
:format(checked, matched, checked - matched - missing, missing,
|
||||
failed, os.clock() - t0))
|
||||
io.write(("shiny: %d recoloured, %d without a variant, %d malformed\n")
|
||||
:format(shinyOk, shinyMissing, shinyBad))
|
||||
|
||||
if matched == checked and failed == 0 and missing == 0 then
|
||||
-- NONE recoloured is a failure, not a quiet zero. It is what a missing or
|
||||
-- unfindable data/shiny_colors.lua looks like, and the first version of this
|
||||
-- test reported PASS through exactly that: 151 species built, every one of
|
||||
-- them without a shiny variant, and nothing in the output that read as
|
||||
-- wrong. A count of zero is now as loud as a malformed pack.
|
||||
if checked > 0 and shinyOk == 0 then
|
||||
io.write("NO SPECIES RECOLOURED -- data/shiny_colors.lua was not found\n")
|
||||
shinyBad = shinyBad + 1
|
||||
end
|
||||
|
||||
-- The oracle diff is the load-bearing assertion and is unchanged: the shiny
|
||||
-- pass must not have moved a single byte of the normal packs. The shiny
|
||||
-- counters are additional, and a malformed variant fails the run -- a pack
|
||||
-- of the wrong length would be read as a corrupt model at runtime.
|
||||
if matched == checked and failed == 0 and missing == 0 and shinyBad == 0 then
|
||||
io.write("PASS -- the Lua extractor reproduces the packer exactly\n")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
@@ -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))
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Every species' battle palette, normal beside shiny, as one HTML page.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Writes
|
||||
-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html
|
||||
--
|
||||
-- ------- what it is actually showing
|
||||
--
|
||||
-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's
|
||||
-- values for a model's texels, and it is already checked against the Python
|
||||
-- that produced it. This is the other end: what those values become after
|
||||
-- ShinyPics puts them through the engine's four-shade battle palette, which
|
||||
-- is where the flat art gets its colour and the only place a mistake there
|
||||
-- shows up.
|
||||
--
|
||||
-- Two rules are visible in the output and both were bugs first:
|
||||
--
|
||||
-- * shade 1 and shade 4 never move. They are the shared paper (255,239,255)
|
||||
-- and the shared ink (25,16,16), not colours anybody chose for this
|
||||
-- animal, and sliding them turned shiny Golbat's white navy.
|
||||
-- * the five TABLE species rotate hue like everyone else, because their
|
||||
-- slide is measured back out of their lookup table rather than falling
|
||||
-- back to a multiply that can only darken.
|
||||
--
|
||||
-- The COLORS pack is whatever PaletteFX defaults to in a headless process
|
||||
-- (the GBC pack). The RED++ pack is a different set of four colours per
|
||||
-- species and would want its own sheet.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local OUT = MOD .. "/.claude/shiny_update/palettes.html"
|
||||
|
||||
-- ------- the mod namespace, enough of it (see tests/shiny_test.lua)
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua
|
||||
-- reads; any mon carrying it is shiny as far as the engine is concerned.
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
-- ------- collect, in dex order
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function hex(c)
|
||||
return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0)
|
||||
end
|
||||
|
||||
local moved, still, missing = 0, 0, 0
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
row.normal = PaletteFX.monPal(Data, row.name)
|
||||
row.palName = PaletteFX.monPalName(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
row.shiny = PaletteFX.monPal(Data, row.name)
|
||||
row.shinyName = PaletteFX.monPalName(Data, row.name)
|
||||
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
row.kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex)
|
||||
or spec.slide)
|
||||
row.slide = slide
|
||||
|
||||
if not (row.normal and row.shiny) then
|
||||
missing = missing + 1
|
||||
else
|
||||
-- how far the two middle shades actually travelled, as the largest
|
||||
-- per-channel step: a row that reads 0 is a shiny nobody can see
|
||||
local d = 0
|
||||
for i = 2, #row.normal - 1 do
|
||||
local a, b = row.normal[i], row.shiny[i]
|
||||
if type(a) == "table" and type(b) == "table" then
|
||||
for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end
|
||||
end
|
||||
end
|
||||
row.delta = d
|
||||
if d >= 8 then moved = moved + 1 else still = still + 1 end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the page
|
||||
local out = {}
|
||||
local function w(s) out[#out + 1] = s end
|
||||
|
||||
w([[<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Shiny battle palettes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 62em; margin: 0 0 20px; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { padding: 3px 10px 3px 0; text-align: left; vertical-align: middle;
|
||||
white-space: nowrap; }
|
||||
th { color: #9aa0aa; font-weight: 400; border-bottom: 1px solid #2a2c34; }
|
||||
tr:hover { background: #1c1e26; }
|
||||
.sw { display: inline-block; width: 26px; height: 20px;
|
||||
border: 1px solid #000; vertical-align: middle; }
|
||||
.fixed { opacity: .45; }
|
||||
.kind-table { color: #ffc46b; }
|
||||
.kind-slide { color: #7fb2ff; }
|
||||
.flat { color: #ff8a8a; }
|
||||
td.n { text-align: right; color: #9aa0aa; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle palettes — normal beside shiny</h1>
|
||||
<p class="lede">What <code>ShinyPics</code> hands the battle pic cache, per
|
||||
species. The first and last shades are the shared paper and ink and are held
|
||||
still on purpose (shown faded); only the two middle shades are the Pokemon.
|
||||
<span class="kind-slide">slide</span> species use Stadium's declared values;
|
||||
the five <span class="kind-table">table</span> species use a slide measured
|
||||
back out of their own lookup table, which is what lets Gyarados reach red.
|
||||
Δ is the largest per-channel step across the two middle shades —
|
||||
a row in <span class="flat">red</span> barely moved.</p>
|
||||
]])
|
||||
|
||||
w(("<p class=\"lede\">%d species · %d visibly recoloured · "
|
||||
.. "%d barely moved · %d with no palette</p>\n")
|
||||
:format(#rows, moved, still, missing))
|
||||
|
||||
w("<table><tr><th>#</th><th>species</th><th>pal</th><th>kind</th>"
|
||||
.. "<th>slide h / s / l</th><th>normal</th><th>shiny</th><th>Δ</th>"
|
||||
.. "</tr>\n")
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local function swatches(cols)
|
||||
if not cols then return "—" end
|
||||
local o = {}
|
||||
for i, c in ipairs(cols) do
|
||||
local fixed = (i == 1 or i == #cols) and " fixed" or ""
|
||||
if type(c) == "table" and c[1] then
|
||||
o[#o + 1] = ("<span class=\"sw%s\" style=\"background:%s\" "
|
||||
.. "title=\"%d,%d,%d\"></span>")
|
||||
:format(fixed, hex(c), c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(o)
|
||||
end
|
||||
local s = row.slide
|
||||
w(("<tr><td class=\"n\">%03d</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"kind-%s\">%s</td><td>%s</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"n%s\">%s</td></tr>\n")
|
||||
:format(row.dex, row.name, tostring(row.palName), row.kind, row.kind,
|
||||
s and ("%.0f° / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
|
||||
s.l or 0) or "—",
|
||||
swatches(row.normal), swatches(row.shiny),
|
||||
(row.delta and row.delta < 8) and " flat" or "",
|
||||
row.delta and tostring(row.delta) or "—"))
|
||||
end
|
||||
|
||||
w("</table></body></html>\n")
|
||||
|
||||
local f = assert(io.open(OUT, "wb"))
|
||||
f:write(table.concat(out))
|
||||
f:close()
|
||||
|
||||
print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette")
|
||||
:format(OUT, #rows, moved, still, missing))
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv
|
||||
--
|
||||
-- Run from the PROJECT ROOT. One species per line, tab separated:
|
||||
--
|
||||
-- dex name spriteFront kind n1 n2 n3 n4 s1 s2 s3 s4
|
||||
--
|
||||
-- where each colour is r,g,b. TSV rather than JSON because there is no JSON
|
||||
-- encoder in this tree and the payload is eight colours and a path.
|
||||
--
|
||||
-- The COLOURS are the point: they come from the real wrap (ShinyPics over
|
||||
-- PaletteFX.monPal), not from a second implementation of it, so what the
|
||||
-- sheet shows is what the game bakes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151
|
||||
and def.spriteFront then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function cols(t)
|
||||
local o = {}
|
||||
for i = 1, 4 do
|
||||
local c = t and t[i]
|
||||
o[i] = (type(c) == "table" and c[1])
|
||||
and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0"
|
||||
end
|
||||
return table.concat(o, "\t")
|
||||
end
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local normal = PaletteFX.monPal(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
local shiny = PaletteFX.monPal(Data, row.name)
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
local kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
io.write(("%d\t%s\t%s\t%s\t%s\t%s\n")
|
||||
:format(row.dex, row.name, row.path, kind, cols(normal),
|
||||
cols(shiny)))
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every battle pic, baked normal beside baked shiny, as one HTML page.
|
||||
|
||||
luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv
|
||||
python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv
|
||||
|
||||
Run from the PROJECT ROOT. Writes
|
||||
mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained
|
||||
(every pic is a data: URI), so it can be opened or sent on its own.
|
||||
|
||||
------- the bake is the engine's, exactly
|
||||
|
||||
src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets
|
||||
its colour, and it does it ONCE at load with mapPixel:
|
||||
|
||||
col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4]
|
||||
|
||||
Four-shade DMG art, keyed on the RED channel alone, snapped to the species
|
||||
palette. That line is reproduced below rather than approximated, because the
|
||||
whole question this sheet answers is what the player will actually see -- an
|
||||
approximation of the bake would be answering a different one.
|
||||
|
||||
The colours come from tools/shiny_pic_dump.lua, which runs the real
|
||||
ShinyPics wrap over the real PaletteFX, so nothing here re-derives them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.getcwd()
|
||||
MOD = "mods/DramaticShapeVoxelMod"
|
||||
OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html")
|
||||
SCALE = 3 # nearest-neighbour, so the pixels stay pixels
|
||||
|
||||
|
||||
def parse_color(s):
|
||||
r, g, b = (int(v) for v in s.split(","))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def bake(img, pal):
|
||||
"""getImage's mapPixel: red channel picks the shade, alpha is kept."""
|
||||
img = img.convert("RGBA")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
f = r / 255.0
|
||||
if f > 0.83:
|
||||
c = pal[0]
|
||||
elif f > 0.5:
|
||||
c = pal[1]
|
||||
elif f > 0.17:
|
||||
c = pal[2]
|
||||
else:
|
||||
c = pal[3]
|
||||
px[x, y] = (c[0], c[1], c[2], a)
|
||||
return img
|
||||
|
||||
|
||||
def data_uri(img):
|
||||
img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
||||
stream = sys.stdin if src == "-" else open(src, encoding="utf-8")
|
||||
rows = []
|
||||
with stream:
|
||||
for line in stream:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
f = line.split("\t")
|
||||
rows.append({
|
||||
"dex": int(f[0]),
|
||||
"name": f[1],
|
||||
"path": f[2],
|
||||
"kind": f[3],
|
||||
"normal": [parse_color(c) for c in f[4:8]],
|
||||
"shiny": [parse_color(c) for c in f[8:12]],
|
||||
})
|
||||
|
||||
cards, missing = [], 0
|
||||
for row in rows:
|
||||
path = os.path.join(ROOT, row["path"])
|
||||
if not os.path.exists(path):
|
||||
missing += 1
|
||||
continue
|
||||
art = Image.open(path)
|
||||
n = data_uri(bake(art.copy(), row["normal"]))
|
||||
s = data_uri(bake(art.copy(), row["shiny"]))
|
||||
cards.append(
|
||||
'<figure class="k-{kind}">'
|
||||
'<div class="pair"><img src="{n}" alt="{name} normal">'
|
||||
'<img src="{s}" alt="{name} shiny"></div>'
|
||||
'<figcaption>{dex:03d} {name}<span>{kind}</span></figcaption>'
|
||||
"</figure>".format(kind=row["kind"], n=n, s=s,
|
||||
name=row["name"], dex=row["dex"])
|
||||
)
|
||||
|
||||
html = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Shiny battle pics</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 64em; margin: 0 0 20px; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
figure { margin: 0; background: #1c1e26; border: 1px solid #2a2c34;
|
||||
border-radius: 6px; padding: 8px; }
|
||||
.k-table { border-color: #7a5a1e; }
|
||||
.pair { display: flex; gap: 6px; background: #fff; border-radius: 3px;
|
||||
padding: 2px; }
|
||||
img { display: block; image-rendering: pixelated; }
|
||||
figcaption { margin-top: 6px; color: #9aa0aa; display: flex;
|
||||
justify-content: space-between; gap: 10px; }
|
||||
figcaption span { color: #6f757f; }
|
||||
.k-table figcaption span { color: #ffc46b; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle pics — normal on the left, shiny on the right</h1>
|
||||
<p class="lede">Each pair is the same four-shade art baked twice, through the
|
||||
palette the game itself would use: <code>getImage</code> keys on the red
|
||||
channel alone and snaps to the species palette, once, at load. The colours
|
||||
come from the live <code>ShinyPics</code> wrap, so this is what the flat
|
||||
battle screen draws — not a preview of it. Bordered cards are the five
|
||||
species Stadium gives a real alternate texture, whose slide is measured back
|
||||
out of that texture rather than declared.</p>
|
||||
<p class="lede">%d species%s</p>
|
||||
<div class="grid">%s</div>
|
||||
</body></html>
|
||||
""" % (len(cards),
|
||||
"" if not missing else " · %d with no art on disk" % missing,
|
||||
"\n".join(cards))
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
size = os.path.getsize(OUT) / 1024.0
|
||||
print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing,
|
||||
size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user