Compare commits

...

64 Commits

Author SHA1 Message Date
DramaticShape 6482325e26 remove fireflies 2026-08-08 14:21:41 -04:00
DramaticShape b8b2e0cd33 shiny capture 2026-08-08 14:02:29 -04:00
DramaticShape bee22507b2 @
capture: open the shot 15% wider

BattleCam.zoom is a multiple of the rig frame height, so above one is
zoomed OUT. 1.15 is fifteen percent wider -- exactly one notch of the
player wheel (ZOOM_STEP).

Applied AFTER pushBattle, because OverworldBattle begin calls
BattleCam.reset() and that puts zoom and zoomGoal back to 1. Both are
set, not just the goal, so the shot opens wide instead of gliding
outward over the first fifth of a second of the recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-08-08 13:59:46 -04:00
DramaticShape 1f9a720625 lineup: shiny Lapras on the open sea (ROUTE_20 mid-water)
Checked rather than assumed: the arena stages fine out there (noArena=0),
which was the open question -- STADIUM A wants clear GROUND and there is none
for fifty tiles in either direction. The water reads as the floor, the
shoreline sits on the horizon, and the sparkle ring lands clear of the shell.
2026-08-08 13:54:31 -04:00
DramaticShape 55145e3faf cave lineup: Mewtwo and Articuno, each where it lives
Cerulean Cave B1F and Seafoam Islands B4F. The RUNS rows gain a rung column
because a cave is where the choice actually matters: STADIUM A stages the
fight on the map and wants clear ground, and a cave floor often has none,
which is what STADIUM B's carried discs exist for. Both are on A -- checked
rather than assumed, and Cerulean Cave stages fine (noArena=0), with the
walls framing the model better than open ground does.

Worth noting for anyone reading the shot: shiny Mewtwo does NOT come out lime
green here. The Stadium values rotate its purple toward blue instead, which
is the fidelity:poor case the guide flags -- the model is right, the
divergence is Stadium's own.
2026-08-08 13:49:26 -04:00
DramaticShape 0091e6d93b the sparkle was playing behind the transition wipe
Armed on the occupant-change edge, which happens while the screen is still
mid-wipe -- so the burst spent its whole three-quarter-second life underneath
it. The instrumentation is what settled it: armed=1, quads=450, which is
45 frames times 10 stars, exactly LIFE. It was never missing. It was drawing
where nobody could see it.

Two gates, because the first one was still wrong. Holding the clock until the
scene DREW the side does not help: the battle renders under the wipe for
about a second before the wipe is gone. The burst now waits until the battle
is the top of the stack -- the wipe popped, somebody watching -- and only
then starts. Armed and released are separate moments now, which is what they
always were.

Also, a shiny no longer discolours a PERSON. Both sides can be holding a
trainer pic rather than a Pokemon (the foe's portrait before the send-out,
the player's own back until "Go!"), and the tint was going straight through
it. Shininess is a fact about a Pokemon, not its owner. sideTexture asks the
same two questions it already used to label the finished texture.

And the capture drivers build the party BEFORE pinning the odds. Pokemon.new
is where shininess is decided, so setting odds to 1 first made the player's
own Pikachu shiny too -- which tinted the player's side, which during the
intro is the trainer sprite. That is what "the player trainer sprite seems
discolored" was.

Lineup is now Charizard, Electrode, Vaporeon, Dratini.

Verified by strip: the burst lands in the frames right after the wipe clears,
and the trainer back sprite is its own colour again.
2026-08-08 13:35:01 -04:00
DramaticShape 120f9716b6 capture drivers: real 1x, one window at a time
A POKEPORT_DRIVER run is deliberately unpaced -- main.lua's pacingEnabled()
returns false whenever the variable is set, so love.run spins as fast as the
machine allows and takes one Game:update(1/60) per turn of the loop. Right
for a screenshot script, wrong for a recording: "wait 180 frames" is three
seconds only by coincidence of hardware.

No engine change needed. The loop is blocked while the driver coroutine is
running, so sleeping inside it before each yield paces the whole thing -- one
logic step per one sixtieth of real time, 1x by construction. Measured with
DS_AUTOCLOSE: asked for 6.0s, took 6.31s, and it errs slow rather than fast.

shiny_one.lua does ONE encounter and then never finishes, which is how the
window stays up: main.lua quits the moment a driver coroutine goes dead. So
closing the window by hand is what ends the take. shiny_run.sh launches the
five in turn and blocks on each, so the next never opens over the top of one
still being recorded, and a bad take can just be closed and re-run.

It also puts options.lua back afterwards. The game persists the whole options
table mid-run, so every capture leaves its display settings on disk.
2026-08-08 13:22:52 -04:00
DramaticShape 322defbbd0 size the sparkle to the Pokemon it belongs to
Flat numbers cannot work here and both previous attempts proved it in
opposite directions. The first ring was 7 world units across, which is inside
anything bigger than a Rattata -- and additive drawing keeps the depth test,
so all sixty quads a frame were rejected and nobody saw them. Correcting by
eye gave a ring 24 across starting 20 units up, which is taller than the
tallest Pokemon in the game: it hung in the sky above a Ponyta with nothing
underneath it.

A mon on the map is 5 to 18 world pixels tall (StadiumMon REF_HEIGHT 14). One
ring cannot fit a Diglett and a Gyarados, so every distance is now a fraction
of the mon: Stadium pushes each side's worldHeight and worldRadius into
ShinyFx every frame -- worldRadius exists precisely so "a caller can size
something to its footprint" -- and the burst is measured off those.

Two more things the pictures showed. The ring is an ELLIPSE with its axes
measured separately: flattened enough to read as a ring from the battle's low
seat, a single radius reaches only a third of the body's height, so the top
and bottom stars sit on the Pokemon. And it OPENS from clear of the body
rather than from a point -- springing out of nothing means every star spends
the first frames stacked at the centre, which is the middle of the mon and
looks exactly like the sparkles being stuck inside it.

Shot against Ponyta and Gyarados, an order of magnitude apart in size: the
ring hugs each silhouette from outside and no star lands on either body.
2026-08-08 13:01:09 -04:00
DramaticShape eb69bc7db8 a rev bump has to actually reach the machines it was bumped for
It did not. pending() short-circuited on available(), and available() was
true for any checkout carrying assets/stadium -- so when REV went to 3 for
the shiny variants, such a machine was never pending, was never asked to
rebuild, and quietly went on serving the old set. ready() was false, so
readPack skipped the save-dir cache and read the shipped normal-only packs:
every shiny Pokemon drawn in its ordinary colours, with nothing on screen
saying why. A driver run sitting at "idle 0/151" for twelve thousand frames
is what surfaced it.

pending() is now keyed on ready(), not available(). A checkout with a ROM
spends one loading screen rebuilding a set it had files for; after that it is
current and never pending again. That was the cost the old short-circuit was
avoiding, and it is worth paying once to make a rev bump mean something.

The other half is the players who imported a ROM through the picker rather
than dropping it in baseroms/. beginFrom builds from the bytes and never
keeps them, so on a rev bump there is no ROM to rebuild from -- and with
available() keyed on ready(), the STADIUM rungs would have vanished off the
options row entirely. usable() now separates "these packs are readable" from
"these packs are current": format and count, deliberately not rev. Stale
packs keep the mode working and keep the rungs offered; only the recolour
waits for a rebuild. Losing the shinies until then is a blemish, losing the
mode is not.

readPack orders the two accordingly: a current cache always wins, a stale one
wins only when there is no shipped set to prefer instead -- so a cache from
an extractor rev we have since fixed cannot shadow good files, while a player
whose only copy IS that cache still gets Pokemon on the field.

Verified by rolling the marker back to rev 2 and launching: ready=false,
usable=true, available=true, pending=true, and the build runs unprompted.
2026-08-08 12:53:59 -04:00
DramaticShape b05c7265d6 a palette transform, and the sprite sheet it is for
The texel transform is wrong for palettes, and silently so. A lookup table
answers only the colours it contains -- the ones its MODEL is painted with --
and the engine's ADVANCED palettes are a different set entirely: BLUEMON's
blue is not any blue on the Gyarados model. Asked to shift a palette, the
table returned it unchanged, so the five table species produced no sprite
shift at all and the most dramatic shiny in the game came out identical.

paletteTransform picks the right tool per species: the slide where there is
one, the tint multiplier (which IS derived from the table) where there is
not. 149 of 151 palettes now move; the two that do not are Jigglypuff and
Wigglytuff, whose shiny genuinely leaves the body almost where it was.

Plus the two tools that make the comparison sheet. Worth saying why it is a
palette job at all: Gen 1 battle pics carry no colour -- they are four-shade
DMG grey, and every bit of colour is the palette laid over them. So a shiny
sprite is the same pixels under a shifted palette, and a sheet built any
other way would be showing something the game never draws.

Sheet at .claude/shiny_update/7_sprites_all151.png, every species beside its
own control.
2026-08-08 12:47:54 -04:00
DramaticShape 6b4dfe6b9b shiny: tint from the body colour, and the fixes the screenshots found
Four things, all found by shooting the feature rather than by reading it.

The flat-pic tint was a no-op for the five colour-table species. tintFor ran
synthetic reference colours through the lookup, none of them were IN it, so
every ratio came back 1 and the tint was discarded -- a shiny Gyarados drew
an ordinary blue pic. Those species now measure the tint from the table's own
entries.

It was also a no-op for most SLIDE species, for a better-hidden reason: a hue
rotation moves red toward cyan and cyan toward red, so averaged over a
balanced set of references the ratios cancel and every species reports no
tint. Charizard and Ponyta both came back neutral. The tint is now measured
against the colour each species is actually MADE of -- a modal body colour,
generated into data/shiny_colors.lua as `dom`. 132 of 151 now carry a usable
tint; the rest genuinely shift too little for one to mean anything.

The sparkle drew sixty quads a frame that nobody could see. It was built to
numbers a tenth of the scale of a mon card -- a ring seven units across,
inside a Gyarados -- and additive drawing keeps the depth test, so all of it
was rejected. Sized against the card now, and pulled toward the camera the
way the move-animation card is.

And the summary PIC cannot be recoloured by touching pixels: the art is
four-shade DMG grey and the colour is applied afterwards by the palette pass.
That attempt is reverted, with the reason left where the next person will
look for it. The star is the designation that works there.

Also: tools/shiny_colors.py now resolves its own paths instead of hardcoding
a worktree, and the extract test gained a hard failure when NOTHING
recolours -- which is what a missing colour table looks like from the
outside, and it passed through it once already.

Evidence in .claude/shiny_update, every case beside its own control.
2026-08-08 12:37:57 -04:00
DramaticShape de54e4ea26 fix canopy issue 2026-08-08 12:27:19 -04:00
DramaticShape 0d245c4ccb Merge branch 'worktree-shiny-system' into shadow-toggle
Shiny Pokemon: derived from DVs via the engine's own Stats.isShiny, models
recoloured as part of the Stadium extraction, flat art tinted, a sparkle on
arrival and a star on the status page.

Verified on the merged tree rather than only on the branch, since
shadow-toggle moved underneath it (the new menus work): the mod loads clean,
tests/shiny_test passes 49, and the extraction still reproduces all 151
packs byte-for-byte against the Python oracle with 151 shiny variants
alongside.
2026-08-08 12:22:24 -04:00
DramaticShape 2907aba6ff new menus 2026-08-08 12:18:35 -04:00
DramaticShape c07aed2449 find the colour table relative to the mod, not the cwd
ShinyPalette loaded data/shiny_colors.lua by guessing cwd-relative paths
when V.data was absent, which is the headless case. Run from the project
root, none of the guesses hit -- so the extraction built all 151 species,
recoloured none of them, and reported PASS. The packs were correct; there
simply were no shiny variants in them, and nothing in the output said so.

Two fixes, because either alone leaves the trap open:

  V.path is now consulted first, so the file is found relative to the MOD
  the way every other resource is. The extract test's stub sets it to
  whatever --mod it was given.

  A count of zero recoloured is now a failure. That is precisely what a
  missing colour table looks like from the outside, and a test that passes
  on it is not testing the feature. It was caught by noticing the number
  change between two runs that differed only in where they were started
  from -- which is too thin a thread to hang it on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:14:16 -04:00
DramaticShape a3fb18a589 shiny Pokemon, on by default
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to
decide it -- and the engine already ships that reading (Stats.isShiny, its
own comment calling it "the RBY virtual shiny", allowlisted for mods
precisely so an indicator mod can call it). Nothing new is stored on a
Pokemon and nothing migrates: every save already contains the answer, and
this starts drawing it. Random DVs land on the pattern 1 in 8192, which is
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 with no second copy of the truth to drift. mon.shiny
is a cache written from the DVs, never read as the source.

The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is
built there, and it is before the battle bakes its sprite, which
battle.started is already too late for. It draws from the mod's own random
stream so installing this does not shift the sequence damage rolls and
encounter slots come out of. Trainers stay ordinary by themselves: the
engine pins their DVs, as the real games do.

The models are genuinely recoloured, as part of the extraction. Each
species is decoded once, packed as usual, then recoloured and packed again
as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees,
saturation and lightness on a -8..+8 scale at 12.5% a step); five species
carry an explicit colour table instead, because Stadium gives them a real
alternate texture that no single slide reproduces -- Jigglypuff's body must
stay pink while its irises rotate to green.

Extraction is the right moment because StadiumFx's generated frames are
still marked there and the packer drops the marker: it is the last point a
flame is distinguishable from a hide. A shiny Charizard has a shiny hide
and an ordinary fire. The normal packs are written BEFORE the recolour, so
they come out byte-identical and stadium_extract_test still diffs all 151
against the Python oracle unchanged -- no format change, no DSM4, no second
implementation to keep in step. REV goes to 3 so an existing cache rebuilds.

Flat art is tinted instead, because the engine bakes a species palette into
a cache with no notion of which individual is drawn. The tint comes from
that species' own slide rather than a generic gold. A multiply can only
darken, so species whose shiny is lighter read quieter there than on the
model; the status page's star is the mode-proof mark.

Tests: 58 assertions in tests/shiny_test.lua, including the colour
transform against 640 real colour pairs lifted from the verified texture
set, the DV model, the read side, and the end-to-end through the engine's
own constructor. stadium_extract_test gains --mod (worktrees have neither
the ROM nor the packs, both gitignored) and now also checks that every
shiny pack is the same length as its twin and actually differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:12:25 -04:00
DramaticShape 66da6cbc4b fix catch regression + add new menus 2026-08-08 12:08:27 -04:00
DramaticShape 121c87b629 fix back facade of buildings 2026-08-08 11:34:04 -04:00
DramaticShape dfe2f1ae1a fix regressions with freemove 2026-08-08 10:42:28 -04:00
DramaticShape 41f1f07342 grass sway during battles, fix south screen culling 2026-08-08 10:11:00 -04:00
DramaticShape 848c9cb29f Merge pull request #142 from DramaticShape/dramatic_go_to_the_polls
Dramatic go to the polls
2026-08-08 09:46:39 -04:00
DramaticShape 87a6c03017 add ball throw mechanic 2026-08-08 09:44:47 -04:00
DramaticShape f0d5ca570a lets go 2026-08-08 09:06:51 -04:00
DramaticShape d5542f9518 Merge pull request #138 from daviDarthemis/dev
Grass improvements
2026-08-08 08:27:19 -04:00
DramaticShape 063ce6e328 Bump version from 1.7.1 to 1.7.2 2026-08-07 21:20:47 -04:00
DramaticShape c9c5a8901a Merge pull request #119 from Elementalzx14/fix/ios-battle-hud
Fix iOS battle HUD
2026-08-07 21:16:31 -04:00
DramaticShape d86d387243 Merge pull request #139 from DramaticShape/stadium_fixes
1.7.1
2026-08-07 21:05:19 -04:00
DramaticShape 89fe722a62 iterate version 2026-08-07 20:54:25 -04:00
DramaticShape 180dd393ea add render distance options 2026-08-07 20:52:43 -04:00
DramaticShape 67bbb3d44b 3DS port boots :) 2026-08-07 20:01:37 -04:00
DramaticShape 4ee15c4f32 Update README to include redistribution policy
Added a restriction on code redistribution after v1.6.0.
2026-08-07 19:03:37 -04:00
Davi D'artemis 5a4979303b visual improvements to the grass 2026-08-07 05:25:37 -03:00
Davi D'artemis a46f194009 Visual improvements: grass movement 2026-08-07 04:38:41 -03:00
DramaticShape 301bd4c1f9 Merge pull request #130 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-06 20:40:38 -04:00
DramaticShape 55bc993ed7 fix battle rotations in vr 2026-08-06 20:31:01 -04:00
DramaticShape 6080531b08 add diorama vr modes 2026-08-06 19:57:11 -04:00
DramaticShape 53fff766a4 revert battle locations temporarily 2026-08-06 19:56:39 -04:00
DramaticShape 00f2b0bb9a Merge pull request #128 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-06 18:24:30 -04:00
DramaticShape c8555e6820 update CUT tree voxelization and mansion computer desks 2026-08-06 18:15:28 -04:00
DramaticShape 91d9a37e8f update voxelizations of celadon diner stools and tables. Update square table voxelization in celadon mansion. Update mansion computer desks 2026-08-06 17:52:59 -04:00
Alonso Avitia 0d0ec51f51 Fix iOS battle HUD compositing 2026-08-06 00:27:52 -07:00
DramaticShape b50bbe0782 Merge pull request #111 from Code-Grub/fix/roof-surface-silhouette-cap
keep a sloped roof's surface off its own outline cap
2026-08-06 01:13:19 -04:00
DramaticShape 9f74ea7e73 Merge pull request #117 from DramaticShape/stadium_fixes
Update viridan forest FX
2026-08-06 01:12:06 -04:00
DramaticShape 442e9d26d5 add fog/god rays to viridian forest 2026-08-06 01:07:38 -04:00
DramaticShape 6240b50cec update all battle locations 2026-08-05 20:36:46 -04:00
DramaticShape e14cf3de90 Merge pull request #114 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-05 19:10:17 -04:00
DramaticShape 77e0f93315 update compatibility 2026-08-05 19:03:49 -04:00
DramaticShape 20c9061625 Fix rendering for broken mons 2026-08-05 18:30:41 -04:00
Code-Grub dde0879527 keep a sloped roof's surface off its own outline cap
The hip ends of the gabled house and Oak's lab come out as black teeth
marching down the slope instead of battens running with it.

The depth->row map is authored for columns the drawing paints edge to
edge. A tapered column starts further down the band, and the surface was
clamped into its first DRAWN row to stay inside the silhouette -- but a
column's first drawn row is its silhouette CAP, and the cap is outline
black. On a tapered column the map spends most of the roof's depth above
that cap (seven of the gabled house's fifty depth voxels land on it
outright), so the clamp painted one outline pixel the length of the
slope and roofCycle beat against it: black punctuated by the real batten
colour every 4 rows on the house, every 8 on the lab.

Lift to the column's first PAINTED row instead -- the same refusal to
let outline black stand as a face that measure() already makes for the
side faces, which the roof band was never given.

Geometry is untouched: voxel, shell and recess counts are identical for
all 50 templates. verify_roof now asserts no roof surface samples its
column's cap, and tests/roof_cap_shots.lua shoots the two Pallet Town
drawings A/B.

The version bump and CHANGELOG entry are left out deliberately, so this
does not collide with the release flow.
2026-08-05 17:45:56 -04:00
DramaticShape 9e54656fe3 Merge pull request #82 from luisgonzaleznf/fix/water-shader-effect-precision-love12
fix: water shader under LÖVE 12 — make effect()'s parameter precision a define
2026-08-05 17:45:41 -04:00
DramaticShape 8ef4d2908f Merge pull request #98 from DramaticShape/dramatic-stadium
Dramatic stadium
2026-08-04 17:43:43 -04:00
DramaticShape ca10a7b860 update tests/lib 2026-08-04 17:40:59 -04:00
DramaticShape b2ccb14afa stadium menu item fix 2026-08-04 14:26:14 -04:00
DramaticShape 79f8a5dc4a update mod description again 2026-08-04 14:24:37 -04:00
DramaticShape edb9ccfffe update mod description 2026-08-04 14:19:48 -04:00
DramaticShape b7ce0f21d5 enforce 1.0 stadium rom 2026-08-04 14:18:11 -04:00
DramaticShape 7ce268e5a2 fix arrival animation, fix pidgey regression 2026-08-04 13:22:41 -04:00
DramaticShape ecb0b57d26 fix some issues, fail gracefully 2026-08-04 12:55:05 -04:00
DramaticShape 1915654a50 file picker update 2026-08-04 12:32:23 -04:00
DramaticShape 0e22393ec7 add file picker for stadium 2026-08-04 12:30:10 -04:00
DramaticShape f21b3ee597 add stadium extraction screen 2026-08-04 11:30:58 -04:00
DramaticShape bddb9de0ba fix animation glitching out 2026-08-04 10:50:19 -04:00
DramaticShape 74cc08f1bf dramatic stadium 2026-08-04 10:43:35 -04:00
luisgonzaleznf c6b38f8d44 fix water shader under LOVE 12: make effect()'s parameter precision a define
The float params were pinned to mediump to match LOVE 11's forward
declaration of effect(), because a definition whose precisions differ
from the prototype's reads as a second function to some compilers.  LOVE
12 declares it under a different precision, so the pin became the
mismatch there and the shader stopped compiling -- lakes drew flat on
any LOVE 12 + Metal build, iOS included.

The qualifier is a define now, and Water.shader compiles the pinned form
first and the bare one only if that is refused, so whichever prototype a
runtime brought, one of the two agrees with it.  The warning fires only
when both are refused.
2026-08-03 15:14:04 +02:00
107 changed files with 33681 additions and 781 deletions
+40
View File
@@ -5,3 +5,43 @@ __pycache__/
# agent worktrees and local scratch
.claude/
# ------- Pokemon Stadium data
#
# NONE OF THIS SHIPS, and none of it is in the repository. The battle models
# are Pokemon Stadium's own data: what the mod carries is the READER for them
# (lib/StadiumRom, StadiumFragment, StadiumFx, StadiumBuild) and the player
# supplies the cartridge, exactly as this engine already asks them to supply
# the Game Boy ROM it is a recompilation of.
#
# So the ROM itself, everything model_extract/pipeline extracts out of it, and
# the packs tools/stadium_pack.py builds from those are all ignored. What IS
# tracked is the pipeline, the notes, and the two READMEs that say where to
# put a ROM.
#
# At runtime the packs are built on the player's own machine, on first run,
# into the save directory -- never into the mod folder (see StadiumInstall).
# the cartridge, wherever it is dropped, and the checksum note that comes
# with one. The Zone.Identifier pattern has no colon in it on purpose: it is
# an NTFS alternate data stream, and the separator reaches git as U+F03A
# rather than as ':' -- so matching on the suffix alone is what actually works
*.z64
*.n64
*.v64
*Zone.Identifier
model_extract/baseroms/**/checksum.md5
# everything the pipeline extracts from it
model_extract/glb/
model_extract/js/
model_extract/textures/
model_extract/manifest.json
model_extract/moves.json
model_extract/viewer.html
# and the packed models built from those -- the local oracle the Lua
# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with
# tools/stadium_pack.py whenever it is wanted
assets/stadium/
pocket-voxel/
+2
View File
@@ -4,6 +4,8 @@
# The SDK suite and the probes it grew out of. A shipped test that requires
# an engine module reads as a private require against the archive
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
tests/arena_config.lua
tests/arena_editor.lua
tests/arena_pick.lua
tests/battle_shots.lua
tests/dramatic_shape_test.lua
+1027
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 DramaticShape
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+182 -14
View File
@@ -1,5 +1,7 @@
# 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).
@@ -17,10 +19,12 @@ menu.
| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 → 4 → 5 — bend the world over the horizon; 5 is a half sphere |
| the **RENDER DIST** options row | FIT / WIDE / WIDER / WIDEST / OFF — how much of the map the camera bothers to draw. **FIT** is exactly the ground on screen and no more: the trapezoid a tilted camera really frames, which reaches well north of you and flares wide out there — not the square the flat game shows. A connected map falling 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° that is all the row does and the picture is untouched; at **75** the camera sees to the horizon, so something has to name a distance — FIT is the closest, the wider rungs push the world's edge out, **OFF** stops cutting. Not on **1ST** or **3RD**: you are standing in the world there, and the box opens out and away as the camera dives in. **FULL** sets it to FIT |
| `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 |
@@ -80,15 +84,148 @@ on the menu while the foe stands out on the map, and no angle holds a
composition that is half frame and half world — so with it on, the shot holds
the one the rig was solved for.
## STADIUM battles
The **3D-BTL** row has five rungs, which are two choices — what is standing
there, and where:
| rung | the fight |
| --- | --- |
| **2D-3D A** | staged on the map, with the Game Boy's own pics stood up on their tiles |
| **2D-3D B** | those same pics on two discs against the sky, with no map drawn |
| **STADIUM A** | staged on the map, with the Pokémon Stadium battle models |
| **STADIUM B** | those models on the discs |
| **OFF** | the engine's own battle screen |
**A** is the map — real ground, in that place's own weather and light. **B**
is the carried stage, which works everywhere, including the caves and shop
floors that have nowhere to put a fight. Only the STADIUM rungs need a ROM;
**2D-3D B** is generated in Lua and uses the game's own art.
Skinned and animated, playing the animation the move being used actually
calls for — the Stadium ROM's own per-species move table, so **DIG** really
does put Diglett into the ground. Fainting plays the faint and holds there, a
send-out grows the Pokémon out of the ball as it opens and plays the entrance,
and between all of that the standby loop runs. Eyes blink and go dizzy;
Charmander's tail flame and Weezing's gas are drawn over the body.
Taking damage plays nothing, because the set has no damage reaction in it —
the slot that looked like one is each species' default attack, which is why
being hit used to look like swinging. The engine's own screen flash, pic blink
and HP drain are what say "that hurt".
148 of the 151 have models. Exeggutor, Tangela and Magmar come out of the ROM
with corrupt standby loops and stand as their Game Boy battle sprites
instead, on their own tile, in the same arena — the same per-Pokémon fallback
a substitute doll and the pre-send-out trainer pic already take.
**B is for the maps that cannot host a fight.** Half of Kanto's interiors are
furniture, a cave floor can be nothing but corridors, and a map where neither
Pokémon can be *seen* from a low camera is declined outright — which drops you
back to the flat battle screen. B carries its stage, so it works everywhere
and looks the same every time. It is abstracted from the ground, not from the
world: the sky behind the discs is the hour's own, and a fight in a cave is
under that cave's void and its own flat light.
### Getting the models
**They are not in this mod, and they cannot be** — they are Pokémon Stadium's
data. What ships is the reader; you supply the cartridge, exactly as this
engine already asks you to supply the Game Boy ROM it is a recompilation of.
> **You must supply a Pokémon Stadium (US) 1.0 ROM.** Not Stadium 2, not
> another region, not a later revision. Every offset in the reader was
> measured against that one cartridge, and nothing else is promised: a
> different file is either refused outright or builds models that are subtly
> wrong. The mod checks, and says so — on the console, and on the loading
> screen itself if it built from something unexpected.
>
> The reference dump is **md5 `ed1378bc12115f71209a77844965ba50`**, 32 MB.
> The mod does not tell you where to get one, and none ships with it.
1. Open **OPTIONS** and press the **STADIUM ROM** row. It opens your system's
file picker; choose your **Pokémon Stadium (US) 1.0** ROM. `.z64`, `.n64`
and `.v64` all work — the byte order is detected, and the wrong file is
refused with a reason rather than half-built.
2. The 151 models are built on a loading screen that says so and shows a
progress bar, in about ten seconds. The row then reads **READY**.
The ROM itself is **not kept** — it is read, built from, and forgotten, so
the cartridge does not sit in your save directory alongside the models it
produced. Press the row again any time to import a different one.
There is no picker on Android, or on a Linux install with neither `zenity`
nor `kdialog`. Those keep the original route, which still works everywhere:
- Put the **US 1.0** ROM in a `baseroms/` folder beside the game — straight
in it, not in a subfolder — and start the game.
- In a packaged build (and on Android) `baseroms/` goes in the save
directory; the mod logs the exact path on startup when it cannot find one.
On Android that is the app's external-files folder, reachable over USB or
any file manager without root.
Either way, the two STADIUM rungs appear on the 3D-BTL row when it's done.
The built models live in the save directory, not in the mod folder, and are
rebuilt automatically if the format changes or the ROM does. Until they exist
the STADIUM rungs are simply not on the row — skipped rather than shown and
refused, because a setting you can select that then does nothing is worse than
one that is not there.
**This works on mobile.** The extraction is pure Lua — no FFI, no native
helper, no second process — so it runs anywhere LÖVE does. It peaks at about
68 MB of Lua heap (32 MB of that the cartridge itself) with a working set that
does not grow across the run, and `tests/stadium_budget_test.lua` fails if
either stops being true. On Android the save directory is the app's
external-files folder, so `baseroms/` there is reachable over USB or a file
manager without root; the build is slower than a desktop's ~7 s but runs one
species a frame behind the progress bar either way.
Developers can pre-build them with `tools/stadium_pack.py`, which reads the
same ROM through `model_extract/pipeline`. That path is also the *oracle*:
`tests/stadium_extract_test.lua` runs it and the in-game Lua extractor over
the same cartridge and requires all 151 packed files to come out byte for byte
identical.
## VR
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
through OpenXR on Windows — SteamVR, Oculus or WMR.
The **VR** options row (OFF / STANDARD / DIORAMA / DIORAMA-MR, off by
default) drives a PCVR headset through OpenXR on Windows — SteamVR,
Oculus or WMR.
Both free-roam rungs put the headset in the player's *head*: a boom that
seats its wearer three cells behind their own body is a reliable way to make
people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the
walk and the sprites the same way.
**STANDARD** follows the VOXEL ladder. Both free-roam rungs put the
headset in the player's *head*: a boom that seats its wearer three cells
behind their own body is a reliable way to make people ill, so **3RD** in
VR is **1ST** in VR. The rung still changes the walk and the sprites the
same way.
### DIORAMA
**DIORAMA** is one presentation instead of a ladder: the world is always a
model on the table, and the model is a *thing in the room*.
- **A viewport.** Everything outside an invisible **box** centred on the
view is not drawn — a square slab of Kanto sitting in the air rather
than a map running off to a horizon, cut with a hard edge, because a
flat world is a thing with sides and the sides are what say so. The sky
behind is the same one the flat screen has.
- **V-CURVE changes its shape.** With the bend on the world is not flat
any more, and a square cut through a little globe is a lie about what is
being looked at — so the box becomes a **ball** whose rim is a
**gradient** dissolving into the sky. One click of the left stick throws
the row and swaps between the two readings of the same model.
- **A staged fight** ignores both and cuts a vertical pillar about the
arena, always with the dissolved rim, which lifts the fight out of the
map as a floating disc.
- **The grips** take hold of it: one hand carries the model anywhere in
the room, both hands turn it and open the viewport out to whatever you
spread your hands to.
- **The left stick's click** throws **V-CURVE** to its top rung and back,
rather than stepping views — there is no 2D diorama and no first-person
one, so the ladder is held on an orbit rung while the mode runs.
**DIORAMA-MR** is the same mode with the background keyed pure green, for
a mixed-reality capture that composites the model into your own room.
### VR controls
@@ -101,17 +238,16 @@ alongside.
| left stick | move — grid-walks the diorama, free-walks 1ST |
| A / B (X / Y on the left hand) | A / B |
| either trigger | START |
| left stick click | step the VOXEL angle ladder (same as the "3" key) |
| right stick up / down | *diorama only* — zoom the model |
| left stick click | *STANDARD* step the VOXEL angle ladder (same as the "3" key); *DIORAMA* — throw **V-CURVE** to its top rung and back |
| right stick up / down | *tabletop* — zoom the model |
| right stick left / right | *1ST only* — snap-turn 45°, or turn smoothly with **SMOOTH TURN** on |
| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height |
| one grip squeezed | *STANDARD* — drag the table's height; *DIORAMA* — carry the model wherever that hand goes |
| both grips squeezed | *DIORAMA only* — turn the model with your hands, and open or close the viewport by spreading them |
| head | *1ST and battles* — look; FreeMove walks where you look |
| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen |
## Licenses
This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
It redistributes one third-party binary:
- **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader
@@ -128,4 +264,36 @@ geometry and shape profiles are derived from the tile and sprite data of
the original game, as documented by the
[pret/pokered](https://github.com/pret/pokered) disassembly. No ROM
data, artwork or audio is included; the mod reads the assets the host
game already has.
game already has.
### Acknowledgements — pret/pokestadium
The STADIUM battle models are read out of the player's own Pokémon Stadium
(US) 1.0 cartridge by original code in [`lib/`](lib) and
[`model_extract/`](model_extract). **That code exists because of
[pret/pokestadium](https://github.com/pret/pokestadium)**, the community
decompilation of that game, which is the reference this mod's reader was
written against. Specifically, it is where the following came from:
- the bone matrix chain, and the fact that scale is kept *out* of it and
applied only at draw time (`func_800143C0`) — the single most important
thing to get right in the whole rig, and not guessable from the data
- the rotation basis and its row-vector `Rx·Ry·Rz` order
(`func_8000F730`, `src/F420.c`)
- the animation player's frame counter and loop-start behaviour
(`func_80016FBC`), and the texture-animation sampler that *clamps* past
the end of its stream rather than wrapping (`func_80017540`) — which is
the difference between a Pokémon blinking and twitching
- the battle system's per-species animation context slots and the routines
that select them (`func_8432B0A4`, `func_8430506C`, `func_84305A74`)
- the move-id constants the per-species move table is keyed by
**No code, data or asset from that project is included in or redistributed
by this mod**, and none is needed to build or run it. What was taken is an
understanding of the file formats, re-expressed in this mod's own Lua and
Python. If you want to reuse anything from the decompilation itself, get it
from upstream and follow that project's own terms.
No Pokémon Stadium ROM data ships here either. The models are built on the
player's own machine, from a cartridge they supply, into their own save
directory — see [Getting the models](#getting-the-models).
@@ -61,6 +61,10 @@ cues generalize:
| Band containing window/door frames | Vertical facade | Straight extrusion |
| Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion |
| Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning |
| Scattered light shapes on a dark field, bracketed by TWO full-width black rims, shallow band below the lower rim | The **inside of an open container** seen from above, with contents lying in it | Hollow tray: walls to the rims, floor slab, air between — never an extrusion |
| Ellipse drawn wider than tall (e.g. 9x5) | A horizontal circle seen from above — a mouth, a lid, a pot rim | Cut face of a round hull; the aspect ratio is the proof of the top view |
| Arcs above/below a round object's straight flanks, lowest point at the centre column, often a 1px #555 halo outside | The SAME circles seen curving — ground contact and mouth back-edge, i.e. depth, not narrowing | Strip them from the revolve; run the last body row's disc to the floor |
| A side band shearing sideways as it descends (¾-view) | The projection sliding a receding wall, not the wall's position | Un-project: the wall goes where the plan says |
The band table for Red's house, which Blue's house shares verbatim:
@@ -156,9 +160,12 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews).
1. Obtain the sprite; sample to native resolution via block centers.
2. Extract palette + silhouette (light-only flood fill, threshold 130);
review the ASCII mask.
3. Segment rows into bands using the Stage-2 cues; write the band table
before writing any geometry code.
review the ASCII mask — rendered large, not hand-counted.
3. Name the real object first (including whether it is hollow, round or
thin — see "Beyond the house"), then segment rows into bands using the
Stage-2 cues; write the band table as prose, one line per row range
with where each band lands, before writing any geometry code. The
correct reading makes the row arithmetic land exactly.
4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`.
5. Build: extrude verticals (de-outlined interiors) → ledges → recesses →
flat top (mid-row cycling) → sloped solids (overwrite, then trim) →
@@ -214,3 +221,75 @@ right for the raw GB palette but comes out white once the atlas is
recoloured, turning every sloped end into a black-and-white zip. The
drawing's own eave is black / `#555` / black, and using that reads correctly
under every palette.
## Beyond the house: the forms later objects added
The house is all solid masses — every band either lies flat or extrudes.
Later objects forced the taxonomy open, and each addition came from the
same root move: **name the real 3D form first, then ask which surfaces the
drawing shows.** The recurring failure at every step was the *extruded
picture* — and it has a second-order form that survives re-segmentation.
The Bike Shop's toolbox was re-read from "a prop" into "a cabinet with a
pump beside it": named parts, correct plot, de-outlined sides, and still
wrong, because the region read as a cabinet *front* was the inside of an
open box seen from above. Naming the parts is not enough; every REGION
must answer "what surface of the real object is this?" The reliable
arbiter is arithmetic: the correct reading makes the drawn row counts land
exactly (the toolbox: 1 back-wall rim + 6 interior rows + 1 front rim = 8
= the one-tile plot depth). Forcing rows to fit means the reading is wrong.
**Hollow forms.** An open container is the one shape whose model must
contain AIR, which no band table or extrusion can produce. The tray
treatment builds four walls to the drawn rims, lays the top-view band on
the floor of the cavity (its contents — a wrench — come along free, since
they are just pixels of that band), and leaves the space between empty.
Two rules only containers hit: the pane-recess pass must never run on a
one-voxel wall (it deletes the front voxel to expose the one behind, and
there is nothing behind — the wall becomes a hole), and the hollowness
needs its own verification assert, because a later change that refills the
cavity leaves every count looking plausible.
**Round forms.** A drawn ellipse wider than tall is a horizontal circle
seen from above — that one aspect-ratio measurement settles the whole
reading. Straight flanks give diameter and height at once (round in plan,
so drawn width IS depth — the one depth never authored). The arcs above
and below the straight run are the same top and base circles seen curving:
ground contact and mouth edge, not narrowing — revolving them puts the
object on a stem. The hull's chord representation stores one z-interval
per column/row, so a taper is expressible (re-cut the chords, squeeze the
art into the narrowed span so the rim outline survives) but a hollow ring
needs a second chord. Voxel resolution bounds taste: on an 11-wide object
a one-step taper reads as damage and two steps as a cone; pick the step
count and derive the amount.
**Thin forms.** A line drawing cannot be thick. The air inside a bicycle's
frame is what makes it read as a bicycle; extrude each stroke 5 voxels and
the side faces of neighbouring strokes close every gap off-axis — six
bikes become one dark mass. Standee thickness is a vocabulary
(`PINNED_DEPTH`: 1 for paper, 2 for plates and side-on vehicles, 5 for
silhouettes, 10 for objects with a body), and when a standee looks wrong
the first move is to dump the detector's mask — if the mask is a clean
object, thickness is the problem, not segmentation.
**Authored masks.** When a drawing shares its tiles and shades with what
it is painted into, nothing automatic can separate them; the profile
carries a pixel mask instead. A person becomes a `figures` card (flat,
leaning with the camera, standing on its feet — because GB character art
is face-on iconography); an object becomes a `mounted` slab (fixed in the
world, holding the wall's plane, keeping its drawn elevation — because a
side-on drawing is a plane parallel to the wall). And when the backdrop is
a *regular* pattern, the mask should be MEASURED, not hand-drawn:
composite the plain backdrop tile over the same grid and flood from the
border through pixels that still match it — what the flood cannot reach is
the object, sprite-pure and exact.
**Verification, extended.** Isometric previews miss what only the game
shows: shoot in-game at both the ¾ rung and the low rung (front-face holes
and proportion errors are invisible from above), crop and NEAREST-upscale
before judging, and remember the flat rung renders no model at all. Two
cheap renders beat argument: the front-most voxel per (x, y) laid beside
the composited drawing catches anchoring and texel leaks instantly, and
the same render with sunk voxels flagged turns the recess pass into
something you look at. When shared builder code moves, a saved count
baseline diffed after every edit (mind the line endings) is what proves a
generalization is an identity for every model that already shipped.
+88 -64
View File
@@ -23,6 +23,20 @@
--
-- SHOT_DIR=.scratchpad/arenas \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- To change ONE map's spot, or to go over these by eye, run the editor
-- instead -- it is the same choice made in front of the map rather than in a
-- batch. It slides the arena around on a plan of the map with the fit, the
-- clearance and the camera's own sightlines answering live, stages a real
-- battle on the spot when asked, and writes this file back a map at a time,
-- replacing only the lines that changed and leaving every comment here alone:
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
--
-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
-- moved describes where that spot USED to be, and it says so by name at
-- export time -- those are the lines to re-word by hand.
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
@@ -49,14 +63,14 @@ return {
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
-- the whole route admits six bare wide arenas, all in the west cliff
-- corridor; this is the best of them. A flower cluster crosses the far
-- mon's hind legs, which the brief allows -- every alternative put a
@@ -64,13 +78,13 @@ return {
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
-- ------- buildings and caves
--
@@ -78,10 +92,10 @@ return {
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
-- also SEEN, and giving up the apron is usually the difference between a
-- fight in the open and one behind a console.
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
-- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -89,73 +103,79 @@ return {
-- is shot there
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
shape = "wide" },
["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
shape = "narrow", turn = 90, cam = "wide" },
["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
shape = "wide", turn = 90, cam = "wide" },
["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
shape = "wide", turn = 90, cam = "wide" },
["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
-- Silph Co is office floors partitioned into small rooms, so the long lens
-- often lands outside the walls it is meant to be looking between; the
-- floors that could not be framed any other way ask for the wide one.
["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
["SILPH_CO_6F"] = { x = 19, y = 2,
shape = "narrow", turn = 90, cam = "wide" },
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
-- aboard and the long lens always lands outside the hull
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
-- these two decks are byte-identical geometry, so they take the same spot
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
-- ------- towns and the last interiors
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
-- the lab is ten cells by twelve, so the long lens is always off-map, and
-- on it a desk clipped one mon and a pillar the other
["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
-- Saffron's gym is a grid of small walled cells: no wide shape exists
-- anywhere in it, and the long lens sits inside a divider
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
["SILPH_CO_11F"] = { x = 10, y = 6,
shape = "wide", turn = 180, cam = "wide" },
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
-- gym's barrier, so arenas there silently fail the fit test
["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
["VERMILION_GYM"] = { x = 3, y = 2,
shape = "narrow", turn = 90, cam = "wide" },
["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
-- ------- the remaining routes
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
-- than on the rim of beach the land search would otherwise find
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
-- ------- caves, gyms and the Elite Four
@@ -163,25 +183,29 @@ return {
-- None of these tilesets has a grass tile at all, so the no-grass rule
-- constrained nothing here; what constrains them is furniture, rock
-- pillars and how small the rooms are.
["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
["CELADON_GYM"] = { x = 3, y = 4,
shape = "narrow", turn = 90, cam = "wide" },
["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
-- hide a mon, so it borrows the floor below -- the same cave
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
shape = "wide" },
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
["CINNABAR_GYM"] = { x = 9, y = 16,
shape = "narrow", turn = 90, cam = "wide" },
["DIGLETTS_CAVE"] = { x = 14, y = 26,
shape = "wide", turn = 90, cam = "wide" },
["FIGHTING_DOJO"] = { x = 3, y = 5,
shape = "narrow", turn = 90, cam = "wide" },
["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
-- The three gyms the default rig cannot stand back from. Five blocks is
-- further than these rooms are wide, so the eye landed outside the map and
@@ -190,7 +214,7 @@ return {
-- BattleCam), which fits inside the room; the mons come out smaller and all
-- three became stageable. It is asked for HERE, per map, so every area that
-- does not ask keeps the long lens it was framed for.
["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
}
+63
View File
@@ -0,0 +1,63 @@
-- What hangs in the air of each map.
--
-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader
-- folds every surface into, and volumetric god rays -- light let down
-- through an INVISIBLE canopy hanging above the map's real geometry, as
-- if the trees drawn are only the understorey of something taller. The
-- rays are not placed: a per-pixel march (see ForestAtmos) reads the
-- frame's own depth and the sun's own shadow map, so the beams stand
-- exactly where light really breaks between the tree hulls, trees and
-- characters carve dark columns through them, and a wind-blown leaf
-- field at the canopy plane opens and closes them like foliage moving
-- overhead. The light leans along the mod's fixed noon shear (the one
-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
-- rays after dark, pollen adrift in the day's beams and fireflies once
-- they cool.
--
-- A map with no entry here has no atmosphere at all: no fog uniform is
-- raised, no march runs, nothing is spent. That is the contract a new
-- map opts into by adding a line, and what a stale entry degrades to if
-- its map id ever stops existing.
--
-- The knobs, in world pixels unless said otherwise (a map cell is 16):
--
-- canopyY where the invisible canopy hangs. MUST clear the tallest
-- real geometry under it (Viridian's carved tree hulls top
-- at y = 32) -- a beam is alpha ZERO at this height and only
-- fades in below it, so a canopy at or under the tree tops
-- would cut every ray off before it cleared the leaves.
-- fadeTo the height by which a descending ray reaches full strength.
-- fog density how fast distance dissolves into the haze
-- (1 - exp(-density * distance-past-start))
-- start how many pixels out the dissolve begins
-- heightK how quickly the fog thins with ALTITUDE
-- (exp(-y * heightK): 0.02 halves it by y = 35)
-- rays strength overall in-scatter gain on the march
-- reach how far out the march walks, in world px
-- motes count of pollen/dust flecks adrift in the daylight beams
-- fireflies count of the night shift
-- seed the xorshift seed the particle deal runs on
--
-- Two caveats for maps opting in later: the water pass has no fog term,
-- so a lake under heavy haze stays clear-day sharp in its reflections;
-- and the march runs after the water's mirror copy, so beams will not
-- appear IN those reflections either. Neither can bite in a map without
-- water.
return {
["VIRIDIAN_FOREST"] = {
canopyY = 56,
fadeTo = 28,
fog = { density = 0.0045, start = 64, heightK = 0.02 },
-- strength is calibrated against the march's real integral: the
-- under-canopy stretch of an orbit ray is short and thinly dense, so
-- the raw accumulation for a fully lit beam core is a few percent --
-- this gain lands it near +0.3 on screen. Halve it for a whisper,
-- double it for cathedral light.
rays = { strength = 16, reach = 380 },
motes = { count = 96 },
fireflies = { count = 48 },
seed = 0x51D,
},
}
File diff suppressed because it is too large Load Diff
+319 -21
View File
@@ -168,6 +168,10 @@ return {
-- a round drawing stacked two cells high on one cell of plot (the
-- Centers' potted plants): 32px of hull standing in its lower cell
planter = 32,
-- the little trees (Celadon Gym's garden, the overworld's cuttable
-- tree): the round hull squashed front to back, 16px of drawn
-- elevation like every other one-cell tree drawing
sapling = 16,
relief = 3,
bookcase = 32,
stair_e = 16,
@@ -197,7 +201,27 @@ return {
-- cutouts both read wrong for them; the cylinder archetype carves
-- one voxel ball per 16x16 cell from the canopy's darkest-pixel
-- outline, round in depth, so tree rows become rows of real canopies
--
-- The cuttable tree ($2D/$2E/$3D/$3E, the four tiles Cut deletes)
-- takes the same hull SQUASHED: it is Celadon Gym's little tree
-- redrawn pixel-for-pixel on this atlas (same 146/256 silhouette,
-- only canopy highlight texels differ), so the two must stay one
-- model -- the scraggly canopy revolves into a gapped ball, the
-- 2-4px trunk into a thin round column, the root flare into a
-- round mound. It used to sit in the `prop` pool as a 5-voxel
-- standee; see GYM's sapling entry for the reading and for what
-- sapling_squash does. Scanned: the 2x2 grid
-- occurs 32 times on this atlas across the towns and routes, and
-- per-tile counts equal the grid count (32 hits for $2D alone),
-- so no stray occurrence renders as a lone hull. The same four
-- ids form grids on FOREST (8, Safari Zone ground art), HOUSE,
-- MANSION, SHIP_PORT and REDS_HOUSE_2 -- different drawings, id
-- collisions, none of this entry's business. Cut itself only
-- swaps the map block to plain grass, so the pin never sees a
-- cut stump.
cylinder = { 42, 43, 58, 59, 64, 65, 80, 81 },
sapling = { 45, 46, 61, 62 },
sapling_squash = 50,
-- the town sign (blockset 8's SE cell): a standing per-pixel slab
-- 2 voxels thin, transparency respected -- never a solid box
signpost = { 70, 71, 86, 87 },
@@ -220,15 +244,11 @@ return {
-- instead -- see there.)
wall = { 2, 36 },
-- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes
-- -- across the whole tileset they appear only in the five
-- cut-tree blocks): a standing per-pixel cutout 5 voxels deep,
-- black-outline segmented with the pixels the outline encloses
-- kept, its drawn grass dither flooding away as background
prop = { 45, 46, 61, 62 },
-- the ground painted under those pinned props, by the prop tile's
-- own id: the bush stands on plain grass ($2C) -- the very tile
-- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather
-- (the cuttable tree $2D/$2E/$3D/$3E moved to the cylinder hull
-- above)
-- the ground painted under the claimed tree cells, by tile id:
-- the tree stands on plain grass ($2C) -- the very tile Cut
-- leaves behind (field.cutTreeSwaps' after-blocks) -- rather
-- than whatever flat tile its neighbours vote
prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 },
},
@@ -345,6 +365,38 @@ return {
-- the pin is not copied there.
cylinder = { 44, 45, 46, 47,
7, 8, 23, 24 },
-- Celadon's three little trees ($40/$41 canopy over $50/$51
-- trunk): a scraggly canopy over a 2-4px trunk flaring into a
-- round root mound. Every drawn row states its own width, which
-- is exactly what the hull revolves -- the canopy turns into a
-- ball with its drawn gaps kept, the trunk into a thin round
-- column, the mound into a round foot. They used to sit in the
-- `prop` pool ("a trunk is not round"), but the standee rendered
-- as the whole 16x16 cell extruded, background and all -- the
-- extruded picture -- and the trunk IS round; the hull reads it
-- right. The same drawing is the overworld's cuttable tree (see
-- OVERWORLD's sapling entry); one drawing, one model.
--
-- sapling_squash 50 is the one AUTHORED number and the only knob
-- taste moves: the percent of its revolved depth every chord
-- keeps. A full revolve (100) assumes the drawing's width is
-- also its depth, which is honest for the hedge balls and
-- boulders in the `cylinder` pool above but not for a tree --
-- the trunk is a stick, the crown is more air than wood, and at
-- full width the tree filled a whole cell of depth and read as a
-- boulder wearing bark. 50 halves it to an ellipse in plan;
-- the model stays round in section and centred on the cell.
-- The height is NOT authored: the class's 16 (see the `heights`
-- table at the top of this file) is the drawn elevation, which
-- is also the model's top plane, so anything riding a tree cell
-- lands right.
-- Scanned: the 2x2 grid occurs 3 times on this atlas and only
-- in CELADON_GYM -- cells (2,4), (7,5), (5,7) -- and per-tile
-- counts equal the grid count (3 hits for $40 alone), so no
-- stray occurrence renders as a lone hull. DOJO shares gym.png
-- and places none, so the pin is not copied there.
sapling = { 64, 65, 80, 81 },
sapling_squash = 50,
-- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch
-- puzzle's fifteen cans plus the sixteenth beside the leader's
-- platform. An open galvanised bin in the 3/4 view, and its plan is
@@ -405,13 +457,9 @@ return {
can_well = 5,
can_taper = 4,
heights = { can = 9 },
-- The statues and Celadon's three little trees ($40/$41 canopy over
-- $50/$51 trunk). A trunk is not round, so the tree cannot be a
-- ball like the shrubs beside it -- it takes the thin standee pool
-- every interior plant takes, which is also a pool apart from the
-- cylinders it touches.
prop = { 2, 56, 18, 19,
64, 65, 80, 81 },
-- The statues. (Celadon's trees $40/$41/$50/$51 lived here
-- too until they moved to the sapling hull above.)
prop = { 2, 56, 18, 19 },
-- The Hall of Fame's recording machine, the one piece of real
-- furniture in the tileset. It is drawn 32px wide and THREE tile
-- rows tall against the north band, and the detector made a mess
@@ -685,7 +733,15 @@ return {
-- per-cell hulls rather than boxes
canopy = { 4 },
cylinder = { 5, 6, 7, 21, 22, 23,
35, 36, 37, 38, 39, 53, 54 },
35, 36, 37, 38, 39, 53, 54,
-- the Safari Zone's small round trees ($54/$55/$56/$57,
-- one cell, 376 placements across the four safari maps
-- and nowhere else on this tileset): drawn as a canopy
-- ball like the overworld's lone tree, and the detector
-- was boxing them into 16px dither-textured crates.
-- One voxel ball per cell, the same hull the big trees'
-- quarter tiles degrade to
84, 85, 86, 87 },
-- the stumps ($02/$03/$12/$13): a hull whose drawn top is a CUT
-- FACE. The body builds from the bark rows alone, and the drawn
-- ellipse of growth rings projects onto the hull's round flat
@@ -1488,9 +1544,14 @@ return {
-- terrace: their north rim (9/25 = $09/$19) and the
-- pedestal course at the south (85/86/87).
--
-- THE ROUND TABLES in full, because the shape is a compromise. The
-- drawing (block 29, and the same four rows split across blocks 45
-- and 49 in the diner) is
-- THE ROUND TABLES in full, because the shape is a compromise.
-- (The `diner_round_table` template under `buildings` below now
-- models all four placements in full -- octagonal top on its
-- pedestal -- by matching the whole 4x4 grid, which is what a
-- per-tile pin can never do. Everything here stays as its
-- degradation path and as the record of why the pins look the
-- way they do.) The drawing (block 29, and the same four rows
-- split across blocks 45 and 49 in the diner) is
-- $09 $27 $27 $19 an octagonal top seen from above, with
-- $36 $37 $37 $39 a pedestal drawn below its southern
-- $46 $37 $37 $47 rim
@@ -1511,7 +1572,7 @@ return {
-- 8px is the FAR rim (9/25, and the shared 39/54/57) and the
-- pedestal (85/86/87): the far rim is occluded by the 16px top in
-- front of it, and the pedestal is meant to sit low. 16px is also
-- the right height against the 8px `stool` chairs drawn around it
-- the right height against the seat-high `stool` chairs around it
-- -- a terrace table you sit at, not a footstool.
counter = { 9, 21, 25, 36, 37, 38, 39, 41, 48, 49, 52, 53, 54,
57, 85, 86, 87 },
@@ -1541,7 +1602,15 @@ return {
-- drawing carries a full black outline with the floor dither
-- showing at all four corners, so the standee segments cleanly --
-- and `stool` keeps its own pool, apart from anything it touches.
-- The `diner_stool` template (see `buildings` below) now models
-- every placement in full, like the house stool it copies; these
-- pins are its degradation path.
stool = { 7, 8, 23, 24 },
-- the `diner_stool` template stands 5 voxels (the drawn
-- elevation: the seat's front edge at row 10 over legs 11-14),
-- as the house stool does: whoever sits on a stool cell rides
-- this height, not the 8px class default
heights = { stool = 5 },
-- Deliberately NOT pinned:
-- $37 (55) is three different things -- the light half of the
-- checkerboard floor, the interior of the round tables, and
@@ -1827,6 +1896,18 @@ return {
-- 18/19) sitting in a WALKABLE cell, so flat floor until pinned.
-- The 8px standee pool, seat height, as in Red's rooms.
stool = { 2, 3, 18, 19 },
-- ...and the height a figure riding that cell stands at, which is
-- the SEAT and not the backrest: the chair's seat is drawn rows
-- 26-31, six of them, so 6. All three Game Freak developers are
-- placed ON their chair cell (CELADON_MANSION_3F objects at (0,4),
-- (3,4) and (0,7)), and VoxelScene.groundAt reads this pin, not
-- the `mansion_computer_desk` model that now draws the chair -- at
-- the class default 8 they floated two voxels over the seat. The
-- same reading INTERIOR's `stool = 5` carries for Bill's chair,
-- which is this drawing with one white margin column instead of
-- two; these four ids are the mansion desks' chairs and nothing
-- else on this atlas, so the override reaches only them.
heights = { stool = 6 },
-- the potted palms: two cells of drawing (68/69 crown, 8/9 fronds,
-- 70/71 stem, 24/25 pot), mostly silhouette, so the THIN standee
-- pool -- the same numbers the generic HOUSE entry uses, and the
@@ -2895,6 +2976,32 @@ return {
},
},
-- Tiles that are FRONT art: they belong on the drawn facade and nowhere
-- else. A building's back is the same drawing extruded straight through
-- the footprint (lib/Buildings.lua `model`, and the volume path's north
-- face in lib/ChunkMesher.lua), so without this every house wears a
-- second door on its far wall and every Center a POKe sign readable
-- backwards. A cell wearing one of these ids takes the art of the
-- nearest ordinary cell beside it in the same tile row instead -- left or
-- right, whichever tile that row uses more, which is what reaches PAST a
-- gable's sloped corner for the wall behind it.
--
-- Windows are deliberately absent: a back wall with windows is right.
-- These are the doorways, the hanging shop signs and the painted GYM
-- lettering -- the three things a facade has that its back does not.
-- Ids are per tileset, indexing that tileset's own atlas.
frontOnly = {
-- doorway 11/12 over 27/28 (27 is the tileset's own doorTile); the
-- POKe (66/67) and MART (68/69) signs over their bracket row 74; the
-- GYM lettering 47/63 painted across the gyms' upper course.
OVERWORLD = { 11, 12, 27, 28, 47, 63, 66, 67, 68, 69, 74 },
-- the Indigo Plateau and Victory Road entrances: the same doorway
-- block, drawn into the cliff face.
PLATEAU = { 11, 12, 27, 28 },
-- the Safari Zone gate's double door, 42/43 over 58/59.
FOREST = { 42, 43, 58, 59 },
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
-- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its
-- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
@@ -4169,6 +4276,109 @@ return {
roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
slab = 3, frontEave = 0, ledge = nil,
},
-- F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7)
-- (1 placement, scan.lua) -- the long table (F07) at two cells
-- wide, the same drawing to the tile everywhere but the interior
-- column count, and the same read to the row: 0-23 the tabletop
-- seen from above, 24-26 the slab's black/#555/black front edge,
-- 27 the #555 shadow that closes it (slab = 3, folded into the
-- band), 28-30 the base with the legs stopping one row short of
-- the grid. Family numbers unchanged; the `table` pin stays as
-- the degradation path, neutralized where this stamps.
{
id = "mansion_square_table",
tiles = {
{ 38, 39, 39, 41 },
{ 54, 55, 55, 57 },
{ 54, 55, 55, 57 },
{ 60, 58, 58, 59 },
},
roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
slab = 3, frontEave = 0, ledge = nil,
},
-- F07c: the Game Freak office's COMPUTER DESK -- the writing desks
-- of CELADON_MANSION_2F cell (0,5) and CELADON_MANSION_3F cells
-- (0,3), (3,3) and (0,6). scan.lua on the 4x4 grid returns those
-- four and nothing else, and each of the four ids that carry the
-- apron and chair rows (2/3/85/86, 18/19) occurs on this atlas ONLY
-- inside them -- so no stray cell can pick this model up. (The
-- same ids are furniture on the HOUSE and FACILITY atlases; those
-- are different images and none of this entry's business.)
--
-- Rows 0-15 are BILL'S DESK, byte for byte. A whole-crop diff of
-- these tiles against the interior atlas's 11/12/13/14 over
-- 27/28/29/30 comes back empty for all 512 pixels: the same
-- tabletop seen from above -- black rim, white highlight course,
-- grey field -- with the same terminal, cord and 2:1 isometric
-- computer drawn into it. One drawing is one model, so the four
-- parts below are `bills_desk`'s verbatim; that entry carries the
-- readings (paper is flat, the keyboard's keys ride its top face,
-- `plan` = rx makes the computer a cube turned 45 and not a slab).
--
-- Only the FRONT is redrawn, and it is 6 rows where Bill's is 7:
-- 15 the top's own black front edge. This is the row the
-- lid replaces, so it opens the fascia exactly the way
-- Bill's row 16 does, and the desk stands 7 voxels to
-- his 8 -- measured, not chosen.
-- 16-17 the #555 edge lip and the black seam under it: the
-- desktop's own rim, which is why they are `fascia` --
-- that band wraps every side, and a desktop's edge is
-- visible from all four.
-- 18-21 the base: the left leg (the black/#555/black at
-- x0-x2), the open apron between, and the DRAWER
-- PEDESTAL at x20-x31 -- two #555 drawer fronts (rows
-- 16-17 and 19-20) inside a black frame. Each is a
-- non-black region sealed behind its own black outline
-- and under 24px, so the measured recess pass sinks it
-- one voxel and the frame stays proud: the drawer gaps
-- come off the pixels, nothing is authored.
-- Row 21 is the ground line and 22 is the measured one. Rows
-- 22-23 are the desk's cast SHADOW, dithered into the checker
-- floor of the walkable cell in front, so the model builds none of
-- them -- they are floor, not furniture.
--
-- The chair is Bill's chair REDRAWN rather than the same pixels
-- (two white margin columns round the backrest panel where his
-- has one, and it sits at x4-x15 rather than x2-x13), but the same
-- object band for band -- rows 20-21 the backrest top seen from
-- above, 22-31 its elevation -- so it takes his part table with x
-- and `rise` moved: `rise` is the whole plane back down, because
-- the chair stands on the FLOOR and not on the desk.
--
-- Why the grid runs two tile rows past the desk: the artist drew
-- the apron into the WALKABLE cell in front (2/3 + 85/86), and
-- 2/3 also carry the chair's back. A template claims whole TILES,
-- so reading the apron takes the chair with it -- which is what
-- makes the template owe it, exactly as at Bill's. `desk.depth`
-- stops the desk box at its own two cells; the chair keeps the
-- front one. The `table` pin on the top tiles and the `stool` pin
-- on 2/3/18/19 both stay as the degradation path, neutralized
-- wherever this stamps.
{
id = "mansion_computer_desk",
tiles = {
{ 36, 37, 52, 53 },
{ 64, 65, 66, 67 },
{ 2, 3, 85, 86 },
{ 18, 19, 17, 17 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 4,
-- the desk's own plot is its two cells; the grid runs on because
-- its apron and the CHAIR share tiles 2/3
desk = { fascia = { 15, 17 }, base = { 18, 21 }, depth = 2 },
parts = {
{ kind = "flat", x = { 2, 15 }, rows = { 3, 7 } }, -- the notes
{ kind = "upright", x = { 4, 15 }, top = { 8, 12 },
facade = { 12, 13 }, z = 8, depth = 6 }, -- keyboard
{ kind = "upright", x = { 16, 19 }, top = { 8, 8 },
facade = { 8, 9 }, z = 8, depth = 2 }, -- the cord
{ kind = "iso", x = { 19, 30 }, rows = { 1, 13 },
plan = 6, z = 9 }, -- the computer
{ kind = "upright", x = { 4, 15 }, top = { 20, 21 },
facade = { 22, 31 }, rise = -7, z = 22, depth = 10 }, -- chair
},
},
},
HOUSE = {
@@ -4480,5 +4690,93 @@ return {
},
},
},
LOBBY = {
-- F09 on the lobby atlas: the Celadon department store's stools
-- -- the diner's chairs, the roof terrace's, the Game Corner's
-- six rows and the four on 1F (58 placements, the scan's only
-- matches on this atlas). A DIFFERENT drawing from the house
-- stool -- it sits one row HIGHER in the tile (seat top rows 4-9
-- over its front edge at 10 and the legs at 11-14, with a clear
-- floor row below) and its leg detail differs -- but the same
-- object band for band, so it takes the house part table with
-- the bands shifted up one row. The measured ground line (15
-- here, 16 in the house) shifts with them, so the stand is the
-- same 5 voxels, and the tileset's `stool = 5` height override
-- keeps whoever sits here ON the seat. The old stool standee
-- pins stay as the degradation path.
{
id = "diner_stool",
tiles = {
{ 7, 8 },
{ 23, 24 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil,
panes = false,
parts = {
{ kind = "upright", x = { 2, 13 }, top = { 4, 9 },
facade = { 10, 14 }, z = 3, depth = 11,
stretch = true }, -- the stool
},
},
-- F11: the ROUND TABLE of the diner and the roof terrace -- 4
-- placements, all on this atlas (CELADON_DINER cells (0,2) and
-- (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan
-- "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55" matches
-- nowhere else). This is the drawing the long `counter` note
-- above calls a compromise -- its four interior tiles are $37,
-- unpinnable, so the flat treatment let the whole top BE a 16px
-- disc. The template matches the exact 4x4 grid instead, which
-- is what a per-tile pin can never do, and un-projects the three
-- facings: rows 0-23 the OCTAGONAL top seen from above (24
-- top-view rows = 24 depth rows, so the plan is the silhouette
-- itself, a `plan` slab 32x24), rows 24-25 the slab's #555/black
-- fascia folded down its rim, rows 26-31 the PEDESTAL seen under
-- the front edge -- two flattened circles, i.e. horizontal discs:
-- the base (diameter 16, drawn cols 8-23, side rows 30-31, its
-- top wearing the drawn shadow-and-white rows 26-29) and the dark
-- column (diameter 6, cols 13-18, rows 26-27 repeating up the
-- shaft), both on the drawn centre x 16 / plan centre z 12.
-- MEASURED: plan, diameters, centres, slab 3. AUTHORED: tabletop
-- plane 8 -- counter height, developer-tuned (the first cut
-- stood it at the flat compromise's 16px and it read too tall)
-- -- plus base height 2 and the 3-voxel column between. `depth`
-- 3 keeps the plot to the drawn plan; the grid's 4th tile row is
-- the pedestal's own drawing plus one floor tile ($37 again, at
-- the southeast corner), which the claim paints as ground.
-- `scrub` repoints the top's interior field -- the four $37
-- tiles, ALSO the checkerboard floor's light half, which carry
-- the floor's palette in a colorized atlas -- at the same grey
-- sourced from the rim's own field, so the whole top wears the
-- table's palette (the drawn field there is uniform grey;
-- nothing drawn is lost). The old wall/counter pins on the rim
-- tiles stay as the degradation path, and `support` carries the
-- top plane so anything the standee scan finds on these cells
-- rides the tabletop.
{
id = "diner_round_table",
tiles = {
{ 9, 39, 39, 25 },
{ 54, 55, 55, 57 },
{ 70, 55, 55, 71 },
{ 85, 86, 87, 55 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 3,
panes = false, support = 8,
scrub = { { 8, 8, 23, 23 } },
parts = {
{ kind = "disc", cx2 = 32, cz2 = 24, r = 8, rise = 0, h = 2,
side = { rows = { 30, 31 }, x = { 13, 18 } },
cap = { rows = { 26, 29 }, x = { 9, 22 } } }, -- the base
{ kind = "disc", cx2 = 32, cz2 = 24, r = 3, rise = 2, h = 3,
side = { rows = { 26, 27 }, x = { 14, 17 } } }, -- the column
{ kind = "plan", x = { 0, 31 }, rows = { 0, 23 },
fascia = { 24, 25 }, fasciaX = { 8, 23 },
rise = 5 }, -- the top
},
},
},
},
}
+72 -8
View File
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
}
-- ------- which way round the fight stands
--
-- Both shapes above are drawn north-south, with the foe at the top and the
-- player below it, and the camera is solved for that: it sits off the
-- player's shoulder, low and back down the arena's own axis. `turn` swings
-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
-- the camera with them -- so the composition on screen is identical and only
-- the ground under it is different.
--
-- It buys two things.
--
-- A footprint that FITS. The wide shape is three cells by six; an east-west
-- corridor two cells deep has no room for it standing up and all the room in
-- the world for it lying down. Half the maps in Kanto run the other way from
-- the one shape this mode was drawn in.
--
-- And a BACKDROP. A quarter turn moves the camera to a different side of the
-- same patch of ground, so the wall behind the pair becomes the window
-- behind them, or the cliff becomes the valley. Nothing about the shot's
-- geometry changes -- the mons land on the same two screen anchors at the
-- same size -- so this is purely a choice about what is behind them, made
-- per map by somebody looking at it.
--
-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
-- what it is; handled as quarter turns everywhere below.
local function quarters(turn)
local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
return ((q % 4) + 4) % 4
end
BattleArena.quarters = quarters
-- The footprint a shape covers once turned: a quarter or three of a turn
-- swaps how far it reaches in each direction, which is the whole reason a
-- corridor takes one and not the other.
function BattleArena.extent(shape, turn)
if quarters(turn) % 2 == 1 then return shape.h, shape.w end
return shape.w, shape.h
end
-- Where a cell offset inside the shape ends up under the same turn, measured
-- from the turned footprint's own north-west corner -- so the corner an entry
-- names stays the corner, whichever way the fight faces from it.
local function spin(shape, turn, ox, oy)
local q = quarters(turn)
if q == 1 then return shape.h - 1 - oy, ox end
if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
if q == 3 then return oy, shape.w - 1 - ox end
return ox, oy
end
-- Whether a cell is open ground for the purpose above.
--
-- "Open" is the walk test the player themselves answer to, so an arena can
@@ -174,12 +225,19 @@ end
-- Build the record the renderer reads: the two mons' cells and, in world
-- pixels, the centre of each and of the pair.
local function place(shape, x, y)
local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
local px, py = x + shape.player[1], y + shape.player[2]
local function place(shape, x, y, turn)
local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
local ex, ey = x + eox, y + eoy
local px, py = x + pox, y + poy
local w, h = BattleArena.extent(shape, turn)
local arena = {
shape = shape.id,
x = x, y = y, w = shape.w, h = shape.h,
-- carried in degrees, so everything downstream that reasons about the
-- shot -- the camera's base yaw above all -- reads the same number the
-- data file was written with
turn = quarters(turn) * 90,
x = x, y = y, w = w, h = h,
enemyCell = { ex, ey },
playerCell = { px, py },
-- world-pixel centres of the two cells a mon stands on
@@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
-- ocean rather than on a scrap of beach at the edge of the map. Land
-- entries are unaffected: land passes the test either way.
local grid, gw = openGrid(host, true)
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
local arena = place(shape, pick.x, pick.y)
-- measured against the TURNED footprint: an entry that lies the arena
-- down an east-west corridor covers different ground from the one that
-- stands it up, and the fit test is the thing that has to know
local fw, fh = BattleArena.extent(shape, pick.turn)
if fits(grid, gw, pick.x, pick.y, fw, fh) then
local arena = place(shape, pick.x, pick.y, pick.turn)
arena.map = host
-- which camera rig this spot is framed for; nil is the default long
-- lens, "close" the short one small rooms need (see BattleCam)
@@ -321,9 +383,11 @@ end
-- The arena at a given north-west corner, whatever the map says about it.
-- The authoring tool's manual override: a spot chosen by eye rather than by
-- the search, so it can be photographed and judged before it is written down.
function BattleArena.at(x, y, shapeId)
function BattleArena.at(x, y, shapeId, turn)
for _, shape in ipairs(BattleArena.SHAPES) do
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
if shape.id == (shapeId or "wide") then
return place(shape, x, y, turn)
end
end
return nil
end
+21 -2
View File
@@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical)
-- arena's own axis, and the room the player has is all on the far side of
-- that -- out toward square-on. (orbitRange measures exactly that room.)
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
local yaw = steer + (fixed and 0
-- ------- and the quarter turn the arena itself is standing at
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`),
-- and the rig is solved for ONE of them: eye off the player's shoulder,
-- back down an axis that runs north-south. So the whole offset is turned
-- with the ground under it, which leaves the camera in exactly the same
-- place RELATIVE to the two mons -- same distance, same height, same
-- angle -- and therefore lands them on the same two screen anchors at the
-- same size. A turn is a fact about the map, never about the shot.
--
-- It goes in with the drift and the steer rather than beside them because
-- it is the same rotation about the same point; the player's own orbit is
-- then measured from wherever the arena starts, so both stops travel with
-- it and side-on stays side-on.
local base = math.rad(arena.turn or 0)
local yaw = base + steer + (fixed and 0
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
local c, s = math.cos(yaw), math.sin(yaw)
-- the breath scales the whole offset, height included, so the eye moves
@@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical)
local dz = (R.side * s + R.back * c) * k
local eye = { mx + dx, groundY + R.height * k, mz + dz }
local focus = { mx + R.lookX, groundY + R.lookY, mz }
-- the aim's own offset turns with the arena too, and with the BASE alone --
-- the drift and the steer swing the eye about the focus, so a focus that
-- followed them would take the thing being orbited around with it
local bc, bs = math.cos(base), math.sin(base)
local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
-- and the climb: the eye swung UP about the focus, at a constant radius.
-- About the focus so the aim stays nailed to the two mons and only the
+20 -197
View File
@@ -12,15 +12,17 @@
-- and an opaque slab in the corner of the frame is the white field back
-- again by another name.
--
-- And the text flips. A panel over a sunlit meadow is bright and wants black
-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
-- white ones. So the panel's average brightness is measured and the glyphs
-- follow it, with hysteresis so a slow camera drift across the threshold
-- cannot strobe them.
-- The ink does NOT change. There was a pass here that measured each panel's
-- average brightness and flipped the glyphs to white over a dark one, with
-- hysteresis so a drifting camera could not strobe them. It worked, and it
-- was still wrong: the battle menu is the one part of the frame the player
-- reads constantly, and having its colour depend on what the camera happens
-- to be pointing at makes it an unreliable piece of furniture. Gen 1's
-- battle text is black, so it is black -- and the panel's tint is what
-- earns that its contrast, on a cave floor as much as on a meadow.
--
-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
-- a few times a second rather than every frame. The camera drifts at about
-- a pixel a second; brightness cannot outrun that.
-- Removing it also took out a one-pixel GPU readback that ran several times
-- a second purely to answer a question nothing asks any more.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
@@ -37,24 +39,13 @@ local BattleHud = {}
BattleHud.FROST = 0.55
BattleHud.TINT = 0.26
-- The luminance the glyphs flip at, with a dead band so a drift across it
-- settles rather than strobes.
BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs
BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones
-- Frames between brightness readbacks.
BattleHud.SAMPLE_EVERY = 12
-- The frost buffer's height; width follows the source's aspect. Small on
-- purpose: the downscale is most of the blur, and what is read back for the
-- brightness is one pixel of it.
-- purpose: the downscale is most of the blur.
BattleHud.FROST_H = 72
local frost, frostW, frostH = nil, 0, 0
local blurA, blurB = nil, nil
local probe = nil
local frame = 0
local luma = {} -- panel key -> { value, dark, at }
local SHADER = [[
uniform vec2 dir;
@@ -102,7 +93,6 @@ function BattleHud.build(src)
frost = canvasOf(w, h)
blurA = canvasOf(w, h)
blurB = canvasOf(w, h)
probe = probe or canvasOf(1, 1)
if not (frost and blurA and blurB) then
frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
return nil
@@ -149,46 +139,6 @@ function BattleHud.frame()
return frame
end
-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
--
-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
-- canvas, which IS the mean, and that one pixel is read back. Cached for
-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
-- nothing it measures moves faster than that.
local function sampleLuma(key, fx, fy, fw, fh)
local hit = luma[key]
if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
if not (frost and probe and frostW > 0) then return hit and hit.value end
if fw <= 0 or fh <= 0 then return hit and hit.value end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local value = hit and hit.value or 1
local ok = pcall(function()
love.graphics.setCanvas(probe)
love.graphics.setBlendMode("replace", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
love.graphics.setCanvas()
local data = probe:newImageData()
local r, g, b = data:getPixel(0, 0)
if data.release then pcall(data.release, data) end
value = 0.299 * r + 0.587 * g + 0.114 * b
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then return hit and hit.value end
luma[key] = { value = value, at = frame }
return value
end
-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
-- in the source the frost was built from.
local function frostRect(rect, box)
@@ -218,44 +168,14 @@ local function mapper(world)
return world and frostRectWorld or frostRect
end
-- ------- the verdict
--
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
-- single pass and there is only one glyph colour to be had out of it -- and
-- a frame with a black-lettered HUD in one corner and a white-lettered one
-- in the other would read as a bug rather than as adaptation. The DARKER
-- panel decides, because it is the one that cannot afford to be wrong, and
-- the tint below then commits both panels to that reading.
local wasDark = false
function BattleHud.verdict(rects, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local toFrost = mapper(world)
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = toFrost(rect, box)
local v = sampleLuma(key, fx, fy, fw, fh)
if v and (not darkest or v < darkest) then darkest = v end
end
if not darkest then return wasDark end
-- hysteresis: it takes a clear move past the far threshold to flip back,
-- so a camera drifting across the boundary settles instead of strobing
if wasDark then
wasDark = darkest < BattleHud.DARK_LEAVE
else
wasDark = darkest < BattleHud.DARK_ENTER
end
return wasDark
end
-- Draw one HUD panel into the current target, in that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- The tint always pushes AWAY from the glyph colour that is about to be
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
-- gets darker under white text, a bright one brighter under black text.
function BattleHud.panel(rect, box, dark, world)
-- The tint always pushes toward WHITE, away from the black ink that is about
-- to land on it, so the contrast is guaranteed rather than hoped for -- and
-- it is the whole of what makes a fixed ink colour workable over any ground.
function BattleHud.panel(rect, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = mapper(world)(rect, box)
local ok = pcall(function()
@@ -263,93 +183,13 @@ function BattleHud.panel(rect, box, dark, world)
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
love.graphics.draw(frost, quad, rect[1], rect[2], 0,
rect[3] / fw, rect[4] / fh)
local shade = dark and 0 or 1
love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
love.graphics.setColor(1, 1, 1, BattleHud.TINT)
love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
love.graphics.setColor(1, 1, 1, 1)
end)
return ok
end
-- ------- flipping the glyphs
--
-- Over a dark panel the HUD's black text has to go white, and it cannot be
-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
-- times white is still black. The colour channel has to be REPLACED.
--
-- So the HUD is drawn into a scratch layer and that layer is composited back
-- through a shader that whitens whatever is nearly black and leaves the rest
-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
-- everything the HUD draws as ink -- while the HP bar's own greens and reds
-- are well clear of the threshold and come through untouched.
--
-- Composited back into whatever the caller had bound, which is what makes it
-- work in both pipelines without knowing which one it is in: in the colorized
-- one that target is the grayscale BG canvas, where white IS shade 0 and the
-- zone pass then colours the flipped glyphs like every other lightest-shade
-- surface; in the flat fallback it is the screen, where white is white.
local INK = 0.35 -- luminance at or under which a pixel counts as ink
local FLIP = [[
uniform float ink;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
return p * color;
}
]]
local flipShader = nil
local layer = nil
local function getFlip()
if flipShader == nil then
local ok, sh = pcall(love.graphics.newShader, FLIP)
flipShader = (ok and sh) or false
end
return flipShader or nil
end
-- Whether the flip pass can run at all, for the shot driver's log.
function BattleHud.flipReady()
return getFlip() ~= nil
end
-- Run `fn` with its ink whitened. Falls back to running it plainly when the
-- scratch layer or the shader is unavailable, so a driver that cannot do
-- either gets the vanilla black HUD rather than no HUD.
function BattleHud.flipGlyphs(w, h, fn)
local sh = getFlip()
if not sh then return fn() end
if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
layer = canvasOf(w, h, "nearest")
if not layer then return fn() end
end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local ok, err = pcall(function()
love.graphics.setCanvas(layer)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("alpha")
fn()
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then error(err, 0) end
love.graphics.setShader(sh)
pcall(sh.send, sh, "ink", INK)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(layer, 0, 0)
love.graphics.setShader()
end
-- ------- the whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
@@ -358,13 +198,9 @@ end
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
-- into the texture rather than composited into the caller's target -- the world
-- image the quads land on is a colour canvas, and a flip pass over it would
-- whiten the terrain behind the glyphs along with them.
local hudLayer = nil
function BattleHud.layerTexture(w, h, dark, fn)
function BattleHud.layerTexture(w, h, fn)
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
hudLayer = canvasOf(w, h, "nearest")
if not hudLayer then return nil end
@@ -377,9 +213,7 @@ function BattleHud.layerTexture(w, h, dark, fn)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
-- flipGlyphs renders fn into its own scratch layer and composites the
-- whitened result into whatever is bound, which is this canvas
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
fn()
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
@@ -388,21 +222,10 @@ function BattleHud.layerTexture(w, h, dark, fn)
return hudLayer
end
-- The last luminance measured, for the shot driver's log.
function BattleHud.lastLuma()
local best = nil
for _, hit in pairs(luma) do
if not best or hit.value < best then best = hit.value end
end
return best
end
function BattleHud.invalidate()
frost, blurA, blurB, probe = nil, nil, nil, nil
frost, blurA, blurB = nil, nil, nil
frostW, frostH = 0, 0
luma = {}
wasDark = false
layer, hudLayer = nil, nil
hudLayer = nil
end
return BattleHud
+237 -32
View File
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
local BattleScene = {}
-- ------- LET'S GO capture mode's stake in this scene
--
-- One table while a capture session runs, nil otherwise (see
-- lib/CatchThrow.lua, which owns it):
--
-- hidePlayer the player's side stays out of the shot entirely -- no
-- card here, no model (Stadium reads this same table), no
-- pinned back pic (OverworldBattle reads it too)
-- shrink the foe's scale while the ball drinks it in, applied
-- about its chest so it collapses toward the beam
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
-- depth buffer, same flash window, same camera
-- cast(sm) the same ball into the sun's pass
-- sig() a term for the cached shadow signature, so a ball in
-- flight re-casts and a resting scene does not
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
-- BattleState:draw wrap in the GB frame
--
-- It lives HERE, not on OverworldBattle, because every consumer below
-- already requires BattleScene and the one file that writes it requires
-- both -- this is the spot with no require cycle.
BattleScene.capture = nil
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against.
BattleScene.GB_W = 160
@@ -195,14 +218,29 @@ end
local function monCards(arena, groundY, textures)
local out = {}
if not textures then return out end
local cap = BattleScene.capture
for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy
-- capture mode: the player's side is out of the shot (the seat looks
-- over an empty shoulder), and OverworldBattle.textures already
-- skipped rendering it -- this is the belt to that suspender
if side == "player" and cap and cap.hidePlayer then tex = nil end
if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer
out[#out + 1] = { tex = tex.canvas,
model = monMatrix(tex, cell[1], groundY, cell[2],
mirror) }
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
-- the foe drinking into the ball: scaled about its own chest, in
-- world space so the composed card matrix needs no decomposition
if side == "enemy" and cap and cap.shrink then
local k = cap.shrink
local ax, ay, az = cell[1], groundY + 8, cell[2]
model = Mat4.mul(
Mat4.mul(Mat4.translate(ax, ay, az),
Mat4.mul(Mat4.scale(k, k, k),
Mat4.translate(-ax, -ay, -az))),
model)
end
out[#out + 1] = { tex = tex.canvas, model = model }
end
end
return out
@@ -303,24 +341,48 @@ end
-- first drawn in.
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
-- `turn` is in the signature with the corner and the shape: the same corner
-- turned a quarter is a different footprint standing on different ground,
-- and a cast kept from the other one freezes the shadows across it
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
tostring(arena.turn or 0),
tostring(terrain), tostring(token or 0),
-- the cycle keeps running through a fight, and an arena lit
-- 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
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
atlasFor, cards, token, host, neighbors,
water, nbWater)
water, nbWater, groundY)
if not ShadowMap.available() then return end
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
if not ShadowMap.stale(sig) then return end
if not ShadowMap.begin(cx, cy, vw, vh) then return end
-- A DISC RUNG: the two discs are the only ground there is, so they are the
-- only thing the sun has to see besides the Pokemon themselves. Everything
-- below this is a map that is not in the shot.
if arena.discs then
pcall(function()
V.require("StadiumStage").cast(ShadowMap, arena, groundY or 0)
end)
pcall(function() V.require("Stadium").cast(ShadowMap) end)
ShadowMap.finish(sig)
return
end
ShadowMap.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
@@ -354,6 +416,17 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
ShadowMap.snug(card.model))
end
ShadowMap.sprites(false)
-- and the STADIUM models, when that rung is the one running. NOT marked
-- as sprites: that flag exists so a flat card's cut-out is kept off the
-- water (see ShadowMap.sprites), and these are real geometry standing in
-- the world -- a Gyarados at the water's edge should put a Gyarados on
-- 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
@@ -363,6 +436,11 @@ end
-- the one nearer the camera and therefore the one a mismatch would show up
-- against.
function BattleScene.groundY(map, arena)
-- A disc rung's discs are carried, not found: their tops ARE the ground
-- plane, so there is no terrain height to read and reading one would put
-- the stage at whatever elevation the map happens to have at a spot the
-- fight is not actually happening on
if arena and arena.discs then return 0 end
local ok, h = pcall(VoxelScene.groundAt, map,
arena.playerCell[1], arena.playerCell[2])
return (ok and h) or 0
@@ -451,11 +529,37 @@ function BattleScene.render(state, arena, textures, token)
-- no glint in the arena: the drift is the shot breathing, not the player
-- moving, and a shimmer on background windows would fight the mons
Voxel3D.glassGlint = 0
-- the host floor's atmosphere reaches the staged shot at HALF density --
-- a fight in Viridian Forest sits in the same haze the walk there did,
-- thinned so neither mon goes soft -- and its god rays stay out of it:
-- this camera is low and long, and a bright blade across a combatant
-- reads as a rendering fault, not weather. nil almost everywhere.
local ForestAtmos = V.require("ForestAtmos")
local atmos = ForestAtmos.frame(host)
Voxel3D.fog = atmos and { color = atmos.fog.color,
density = atmos.fog.density * 0.5,
start = atmos.fog.start,
heightK = atmos.fog.heightK } or nil
-- A B RUNG stands the fight on two carried discs against the sky, with no
-- map in the shot at all (see StadiumStage). Everything below still runs --
-- the letterbox, the camera solve, the sun, the pins, the tint, the depth
-- of field -- because none of it is about the terrain; what changes is
-- which geometry the two passes draw.
local discs = arena.discs and true or false
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh, water, nbWater = prefetchArena(state, host)
if not terrain then return nil end
local terrain, nbMesh, water, nbWater
if discs then
-- and nothing is meshed for a disc fight, which is the other half of why
-- the rung works everywhere: there is no waiting for a chunk to build, so
-- the first frame of the first battle on a cold map is the finished shot
nbMesh, water, nbWater = {}, nil, {}
else
terrain, nbMesh, water, nbWater = prefetchArena(state, host)
if not terrain then return nil end
end
local lx, ly, s, pw, ph = BattleScene.letterbox()
if not (pw > 0 and ph > 0 and s > 0) then return nil end
@@ -466,7 +570,25 @@ function BattleScene.render(state, arena, textures, token)
end
local groundY = BattleScene.groundY(host, arena)
local cam, pitch = BattleCam.rig(arena, groundY)
-- A capture session brings a camera of its own: the head-on seat, on
-- the arena's axis looking straight at the foe, in place of the solved
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
-- the pins, the sun, the cards yawing to the eye -- is generic over
-- whichever camera this is.
local cam, pitch, capFrameH
local cap = BattleScene.capture
if cap and cap.rig then
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
-- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
-- BattleCam.rig hands back -- the only thing downstream reads it is the
-- grass and flower pull below. A seat that declines to say stands in
-- for a near-LEVEL one rather than a top-down one, which is what every
-- staged seat actually is: the pull grows toward straight down, and a
-- default that guessed the wrong end of that would spend tens of world
-- pixels of bias on a camera standing two cells from its subject.
if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), fh end
end
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
@@ -475,7 +597,8 @@ function BattleScene.render(state, arena, textures, token)
-- the player's zoom is part of this: the sun's box is fitted to what the
-- frame holds, so a shot pulled wide has to light the ground it just
-- brought into view rather than the ground the rig alone would have
local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
/ (BattleScene.GB_H * s)
local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live
@@ -487,7 +610,7 @@ function BattleScene.render(state, arena, textures, token)
local cards = monCards(arena, groundY, textures)
Voxel3D.camera = nil
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
cards, token, host, neighbors, water, nbWater)
cards, token, host, neighbors, water, nbWater, groundY)
-- An opaque void either way. Outdoors the camera is low enough that the
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
@@ -496,6 +619,18 @@ function BattleScene.render(state, arena, textures, token)
-- geometry stops.
local sky = VoxelScene.skyColor(host, 1)
or VoxelScene.skyShade(INDOOR_SHADE, 1)
-- On a disc rung the void is not a backdrop behind the scenery -- it IS the
-- scenery, because the map is not drawn. So outdoors it gets the full
-- treatment the free-roam camera gets: the banded gradient and the hour's
-- own sun or moon hanging in it (Voxel3D.beginScene paints those when the
-- sky it is handed carries bands). Indoors there is nothing to dress: a
-- room's void is one flat shade, which is what a room looks like past the
-- wall, and the disc fight in a cave is lit and coloured as that cave.
if discs and VoxelScene.skyColor(host, 1) then
local Sky = V.require("Sky")
local okDress, dressed = pcall(Sky.dress, sky)
if okDress and dressed then sky = dressed end
end
Voxel3D.camera = cam
-- the sun is turned up for the arena and put back afterwards, so the
@@ -528,6 +663,13 @@ function BattleScene.render(state, arena, textures, token)
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
return
end
if discs then
-- discs: the two platforms, and nothing else. No terrain, no
-- neighbouring maps, no water, no grass and no flowers -- see the
-- matching skips further down. What is behind them is the sky the
-- clear painted.
V.require("StadiumStage").draw(arena, groundY)
else
Voxel3D.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
@@ -549,6 +691,7 @@ function BattleScene.render(state, arena, textures, token)
Mat4.translate(nb.ox, 0, nb.oy))
end
end
end
-- The mons, standing on their tiles. Depth-tested like everything else,
-- so a ledge or a tree between the camera and a Pokemon really is in
-- front of it, and the alpha discard cuts the sprite's own outline out of
@@ -579,24 +722,64 @@ function BattleScene.render(state, arena, textures, token)
end
Voxel3D.glass(true)
Voxel3D.seams(true)
-- and the STADIUM models, inside the same flash window and with the
-- same camera-ward pull, so a Pokemon standing on its tile still wins
-- the depth test against the tile. They manage the wireframe and the
-- glass mask around their own draws (StadiumRig), which is why this
-- sits outside the pair above rather than inside it.
local okStadium, stadiumErr = pcall(function()
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
-- orbit's -- there is no character here for them to overdraw, but the
-- pull is also what keeps a tuft from z-fighting the floor it stands on
local pull = VoxelScene.pull(math.max(pitch, 0.05))
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
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))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
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))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
end
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
if not canvas then return end
@@ -610,25 +793,47 @@ function BattleScene.render(state, arena, textures, token)
-- How wide one overworld square is on screen where each mon stands, in
-- GB pixels. This is what the pics are scaled to: a mon covers its own
-- square and no more, at whatever the drift has done to the distance.
--
-- Measured along BOTH map axes and answered as the larger, as a full
-- 2D screen distance. One axis alone breaks the moment a camera looks
-- ALONG it: the capture seat stands on the arena's own axis, and on a
-- quarter-turned arena that axis is world X -- the ±X probe points
-- then project to the same pixel and the span reads zero, which
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
local half = BattleScene.CELL / 2
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
if not (pl and pr and el and er) then return end
local function cellSpan(wx, wz)
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
lx, ly, s, pw, ph)
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
lx, ly, s, pw, ph)
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
lx, ly, s, pw, ph)
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
lx, ly, s, pw, ph)
if not (x1 and x2 and x3 and x4) then return nil end
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
return math.max(ew, ns)
end
local pSpan = cellSpan(arena.player[1], arena.player[2])
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
if not (pSpan and eSpan) then return end
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
playerSpan = math.abs(pr - pl),
enemySpan = math.abs(er - el),
playerSpan = pSpan,
enemySpan = eSpan,
-- the letterbox, so the depth-of-field pass can put its sharp band on
-- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
-- the camera and its combined matrix, for anything that reasons
-- about this shot from outside the render -- the capture mode's
-- throw is solved in these (aim errors along this eye's own right
-- and forward, contact judged through this vp)
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
vp = vp,
-- and the hour's light, for anything drawn over this shot that is NOT
-- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
+238 -11
View File
@@ -134,9 +134,112 @@ local function profile()
end
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
local frontSets = {} -- tileset id -> { [tile] = true } or false
-- The tileset's front-only tiles as a set (data/voxel_heights.lua
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
-- on a facade and on no other face of the same building. nil when the
-- tileset names none, which is every indoor one.
function Buildings.frontOnly(tilesetId)
local hit = frontSets[tilesetId]
if hit == nil then
local s = profile()
local list = s and s.frontOnly and s.frontOnly[tilesetId]
if list then
hit = {}
for _, id in ipairs(list) do hit[id] = true end
else
hit = false
end
frontSets[tilesetId] = hit
end
return hit or nil
end
-- ------------------------------------------------------------------ read --
-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
-- the front shows. The facade extrudes straight through the footprint, so
-- the far wall is the drawing again -- and read from behind it is the
-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
-- wearing one takes the art of an ordinary cell beside it in the same tile
-- row.
--
-- The donor is chosen per RUN of front-only cells, not per cell, so a
-- two-tile doorway comes out as two tiles of the SAME wall rather than
-- borrowing left from one side and right from the other. Between the two
-- neighbours the one whose tile the row uses more often wins, which is
-- what reaches past a gable's sloped corner (a 4x2 house draws its door
-- against the slope: the corner is unique to the row, the wall beside it
-- is not) for the wall the back should actually wear.
--
-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
-- "unchanged"; nil when the drawing has no front-only tile at all, which is
-- most of them.
local function backMap(tiles, bw, bh, W, inside, frontOnly)
if not frontOnly then return nil end
local donor, any = {}, false
for r = 1, bh do
local row = tiles[r]
local freq = {}
for c = 1, bw do
local id = row[c]
if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
end
local c = 1
while c <= bw do
if frontOnly[row[c]] then
local c1 = c
while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
local l, rt = c - 1, c1 + 1
local pick = nil
if l >= 1 and rt <= bw then
pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
elseif l >= 1 then
pick = l
elseif rt <= bw then
pick = rt
end
-- a row that is front-only end to end has no donor; it keeps its
-- own art rather than inventing one
if pick then
for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
any = true
end
c = c1 + 1
else
c = c + 1
end
end
end
if not any then return nil end
local back = {}
for cell, dc in pairs(donor) do
local r, c = math.floor(cell / bw), cell % bw
for oy = 0, 7 do
local sy = r * 8 + oy
for ox = 0, 7 do
local i = sy * W + c * 8 + ox
local j = sy * W + dc * 8 + ox
-- The donor must be DRAWN, or the substitution would hand the wall
-- a texel from outside the silhouette. One row up is tried first,
-- because the drawing's last row is the black threshold the
-- building stands on: the doorway paints it (a door sits on the
-- ground) and the wall beside it does not, so at the base course
-- the same row of the donor column is off the shape. The model
-- lifts that column's foot by exactly one row for the same reason
-- (see `at`), and lifting the donor with it is what makes the back
-- wall's bottom course continuous.
if not inside[j] then j = j - W end
if j >= 0 and inside[j] then back[i] = j end
end
end
end
return back
end
-- Composite the template out of the atlas and flood the silhouette in from
-- the border. Returns flat arrays indexed y * W + x.
--
@@ -149,7 +252,7 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
-- is built from the complete drawing and the tower rises to its real
-- height instead of folding as two half-buildings.
local function read(t, data, perRow)
local function read(t, data, perRow, frontOnly)
local tiles = t.tiles
if t.topRows then
tiles = {}
@@ -244,7 +347,8 @@ local function read(t, data, perRow)
end
end
end
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
back = backMap(tiles, bw, bh, W, inside, frontOnly) }
end
-- --------------------------------------------------------------- measure --
@@ -264,6 +368,27 @@ local function measure(sp, t)
top[x] = r
end
-- The row a column's roof SURFACE may sink to. `top[x]` is the
-- silhouette cap -- the black the drawing closes its shape with -- and
-- the depth map spends most of a tapered column's depth above it, so
-- clamping onto `top[x]` paints that one outline pixel the length of
-- the slope and the courses beat against it. The surface belongs on the
-- first PAINTED row instead: the same refusal to let the outline stand
-- as a face that the side faces already make below.
local surfaceTop = {}
for x = 0, W - 1 do
local y = top[x]
while y < roofRows and sp.inside[y * W + x]
and sp.col[y * W + x] == BLACK do
y = y + 1
end
if y < roofRows and sp.inside[y * W + x] then
surfaceTop[x] = y
else
surfaceTop[x] = top[x]
end
end
-- The drawing's own ground line: the row after the last drawn one. A
-- building ends on the black threshold row it stands on (ground == H),
-- but furniture is drawn standing on open floor -- the lab table's
@@ -386,7 +511,7 @@ local function measure(sp, t)
-- building. `depthPx` names it in voxels, for an object whose real
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
-- standing in the middle of its own cell, not a thing that fills a plot.
return { top = top, ytop = ytop,
return { top = top, surfaceTop = surfaceTop, ytop = ytop,
D = t.depthPx or ((t.depth or #t.tiles) * 8),
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
@@ -436,7 +561,8 @@ local function deskSetModel(sp, pr, t)
local function buildParts(plane)
for _, p in ipairs(t.parts) do
Budget.tick()
local x0, x1 = p.x[1], p.x[2]
local x0 = p.x and p.x[1] or 0
local x1 = p.x and p.x[2] or (W - 1)
if p.kind == "flat" then
-- drawn row = depth row by default; `z` renames the origin when
-- the flat sits below the desk's own drawn top span (the Center
@@ -561,6 +687,94 @@ local function deskSetModel(sp, pr, t)
end
end
end
elseif p.kind == "plan" then
-- A PLAN part is a slab whose plan IS the drawn top view: the
-- band's silhouette becomes the footprint pixel for pixel
-- (drawn row = depth row, the same 1:1 every tabletop is drawn
-- with), so an octagonal top stands as an octagon rather than
-- the box no rectangular band can escape. The top layer wears
-- the band itself, outline and all; the rim layers below wear
-- the drawn fascia rows folded down the edge (x clamped into
-- the drawn fascia's span), and the slab's unseen interior the
-- field's dark texel.
local r0, r1 = p.rows[1], p.rows[2]
local f0, f1 = p.fascia[1], p.fascia[2]
local fx0, fx1 = p.fasciaX[1], p.fasciaX[2]
local rise = p.rise or 0
local h = (f1 - f0 + 1) + 1
if rise + h > ytop then ytop = rise + h end
local function drawn(sx, z)
return sx >= x0 and sx <= x1 and z >= 0 and z <= r1 - r0
and inside[(r0 + z) * W + sx]
end
for z = 0, r1 - r0 do
if z >= 0 and z < D then
local sy = r0 + z
for sx = x0, x1 do
if inside[sy * W + sx] then
put(sx, rise + h - 1, z, sy * W + sx)
local edge = not (drawn(sx - 1, z) and drawn(sx + 1, z)
and drawn(sx, z - 1) and drawn(sx, z + 1))
for y = rise, rise + h - 2 do
if edge then
local fsx = math.max(fx0, math.min(fx1, sx))
put(sx, y, z, (f0 + (rise + h - 2 - y)) * W + fsx)
else
put(sx, y, z, pr.shadeTexel[DARK])
end
end
end
end
end
end
elseif p.kind == "disc" then
-- A DISC part is ROUND IN PLAN -- the pedestal column and base
-- the projection can only draw from the front. Centre and
-- radius are measured off the drawn widths (a flattened arc is
-- a horizontal circle seen from above); the circular footprint
-- is synthesized like any continued geometry, and every voxel
-- still wears the drawing: the side folds the drawn face-on
-- rows around the hull (x clamped into the drawn span, rows
-- repeating up the height), and `cap` lays the drawn top-view
-- rows over the top layer's interior, drawn north rows to the
-- plan's north. `cx2`/`cz2` are DOUBLED plan centres, so an
-- even diameter keeps its centre between two voxels instead of
-- limping one off.
local r, rise, h = p.r, p.rise or 0, p.h
local s0, s1 = p.side.rows[1], p.side.rows[2]
local sa0, sa1 = p.side.x[1], p.side.x[2]
local sn = s1 - s0 + 1
if rise + h > ytop then ytop = rise + h end
local function inDisc(x, z)
local dx = 2 * x + 1 - p.cx2
local dz = 2 * z + 1 - p.cz2
return dx * dx + dz * dz <= 4 * r * r
end
local zlo = math.floor((p.cz2 - 2 * r) / 2)
for x = math.floor((p.cx2 - 2 * r) / 2),
math.floor((p.cx2 + 2 * r) / 2) do
for z = math.max(0, zlo),
math.min(D - 1, math.floor((p.cz2 + 2 * r) / 2)) do
if inDisc(x, z) then
local edge = not (inDisc(x - 1, z) and inDisc(x + 1, z)
and inDisc(x, z - 1) and inDisc(x, z + 1))
for y = rise, rise + h - 1 do
local sx, sy
if p.cap and y == rise + h - 1 and not edge then
local c0, c1 = p.cap.rows[1], p.cap.rows[2]
sy = math.min(c1, c0 + math.floor((z - zlo)
* (c1 - c0 + 1)
/ (2 * r)))
sx = math.max(p.cap.x[1], math.min(p.cap.x[2], x))
else
sy = s0 + (rise + h - 1 - y) % sn
sx = math.max(sa0, math.min(sa1, x))
end
put(x, y, z, sy * W + sx)
end
end
end
end
else
local tr0, tr1 = p.top[1], p.top[2]
local fr0, fr1 = p.facade[1], p.facade[2]
@@ -882,6 +1096,7 @@ local function model(sp, pr, t)
local W, H, D = sp.W, sp.H, pr.D
local slab, roofRows = t.slab, t.roofRows
local top, ytop, ground = pr.top, pr.ytop, pr.ground
local surfaceTop = pr.surfaceTop
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
-- columns undrawn in the roof band; they carry no roof at all, and the
@@ -923,6 +1138,11 @@ local function model(sp, pr, t)
local T = {}
for x = 0, W - 1 do T[x] = ytop - top[x] end
-- the back layer's texel: the drawing again, minus what only the front
-- may wear (see backMap)
local back = sp.back
local function backOf(i) return (back and back[i]) or i end
local function at(x, y, z)
if x < 0 or x >= W then return nil end
local tx = T[x]
@@ -931,11 +1151,12 @@ local function model(sp, pr, t)
if top[x] < roofRows
and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then
if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then
-- the surface itself. Clamping the row into the column's first
-- drawn row keeps the flank battens running down the slope
-- instead of falling off the silhouette.
-- the surface itself. Lifting the row into the column's first
-- PAINTED row keeps the flank battens running down the slope
-- instead of falling off the silhouette -- and off its cap, which
-- is outline black and belongs to the rim, not to the surface.
local sy = roofSy[z]
if sy < top[x] then sy = top[x] end
if sy < surfaceTop[x] then sy = surfaceTop[x] end
return sy * W + x
end
-- The rim reproduces the eave the drawing itself paints under the
@@ -958,7 +1179,11 @@ local function model(sp, pr, t)
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
local sy = ground - 1 - y
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
return sy * W + x
-- z < 0 is the awning's NORTH end: same substitution the wall
-- behind it makes, so a band that carries a sign does not carry
-- it round the back
local i = sy * W + x
return z < 0 and backOf(i) or i
end
return nil
end
@@ -980,7 +1205,7 @@ local function model(sp, pr, t)
if pr.recess[i] then return nil end
return i
end
if z == 0 then return i end
if z == 0 then return backOf(i) end
return pr.interior[i]
end
@@ -1251,7 +1476,8 @@ function Buildings.build(S, map, data, perRow)
-- detector they stood as a second half-building.
models[key] = {}
else
local sp = read(t, data, perRow)
local sp = read(t, data, perRow,
Buildings.frontOnly(tileset.id))
local pr = measure(sp, t)
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
end
@@ -1362,6 +1588,7 @@ end
function Buildings.invalidate()
spec = nil
models = {}
frontSets = {}
end
return Buildings
+1770
View File
File diff suppressed because it is too large Load Diff
+50 -7
View File
@@ -52,6 +52,7 @@ local V = ...
local Assets = require("src.render.Assets")
local Structures = V.require("Structures")
local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Voxel3D = V.require("Voxel3D")
local Budget = V.require("BuildBudget")
@@ -102,6 +103,12 @@ local SIDES = {
{ 0, -1, 6 }, -- -Z north
}
-- How far sideways a face reaches for ordinary wall when the column it
-- stands over draws a doorway or a sign (see wallTile), nearest ring
-- first. Left before right at each distance is arbitrary and only decides
-- symmetric cases.
local SPAN = { { -1, 1 }, { -2, 2 } }
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
@@ -248,6 +255,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
return s and s.h or 0
end
-- The tiles that belong on a DRAWN FACADE and nowhere else -- doorways,
-- shop signs, the gyms' lettering (data/voxel_heights.lua `frontOnly`).
-- A volume folds its column's drawing up all four sides, so without this
-- a house's back and flanks each carry their own copy of its front door.
-- The face keeps the same map row and reaches sideways for an ordinary
-- column instead, which is the neighbouring course of the same wall.
-- The search stays inside the structure -- a neighbour column with no run
-- of its own is the ground beside the building, and a doorway that
-- borrowed grass would be a hole. Two columns is as far as it needs to
-- reach: every doorway in the game is two tiles wide.
local frontOnly = Buildings.frontOnly(tileset.id)
local function wallTile(tx, ty)
local tile = map:tileAt(tx, ty)
if not (frontOnly and frontOnly[tile]) then return tile end
for d = 1, 2 do
for _, nx in ipairs(SPAN[d]) do
nx = tx + nx
if S.runs[keyOf(nx, ty)] then
local n = map:tileAt(nx, ty)
if not frontOnly[n] then return n end
end
end
end
return tile
end
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
local function uvRect(tile, vTop, vBot)
local ax = (tile % perRow) * 8
@@ -605,13 +638,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- drawing itself (full brightness); the other sides wear
-- the same rows darkened, so a building's flank matches
-- its face instead of smearing one tile
local sy
if d == 6 then
src = map:tileAt(tx, math.min(run.front,
run.north + band))
sy = math.min(run.front, run.north + band)
else
src = map:tileAt(tx, math.max(run.north,
run.front - band))
sy = math.max(run.north, run.front - band)
end
-- the south face IS the drawing and keeps every tile of
-- it; the back and the flanks are the same wall seen from
-- somewhere the door and the sign are not
src = (d == 5) and map:tileAt(tx, sy) or wallTile(tx, sy)
if d == 5 then shade = 1 end
elseif s.art == "upright" then
-- profile-authored upright (a pinned wall or furniture
@@ -812,18 +848,25 @@ function ChunkMesher.build(map, bodyOnly, masks, split)
return sink.finish(), waterSink and waterSink.finish() or nil
end
local function quadsMesh(quads)
local function quadsMesh(quads, grass)
if #quads == 0 then return nil end
local verts, indices, n = {}, {}, 0
for _, q in ipairs(quads) do
for i = 1, 4 do
local c = q[i]
local uv = q.uv and q.uv[i] or { q.u, q.v }
verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade }
local v = { c[1], c[2], c[3], uv[1], uv[2], q.shade }
if grass then
v[7], v[8], v[9], v[10] = q.sway or 0, q.cx or 0,
q.cz or 0,
q.firefly and 2 or (q.leaf and 1 or 0)
end
verts[#verts + 1] = v
end
Voxel3D.pushQuad(indices, n)
n = n + 1
end
if grass then return Voxel3D.newGrassMesh(verts, indices) end
return Voxel3D.newMesh(verts, indices)
end
@@ -832,7 +875,7 @@ end
-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
-- terrain could never do this).
local function buildGrassMesh(map)
return quadsMesh(Structures.forMap(map).grassQuads)
return quadsMesh(Structures.forMap(map).grassQuads, true)
end
-- The flower billboards as their own mesh, for the same reason as the
+344
View File
@@ -0,0 +1,344 @@
-- The DIORAMA modes: Kanto as a model you can pick up.
--
-- STANDARD VR presents whatever rung the player is on -- the orbit rungs
-- become a tabletop, 1ST stands you inside the world (see lib/VR.lua).
-- DIORAMA is a different promise, and it is one promise rather than a
-- ladder: the world is ALWAYS the model on the table, seen from outside,
-- and what the headset adds is that the model is a THING IN THE ROOM --
-- grab it, turn it, set it down somewhere else, decide how much of it you
-- want to be holding.
--
-- Two pieces make that read, and this file owns both.
--
-- THE VIEWPORT. Everything outside an invisible BOX centred on the view
-- is simply not drawn -- the Final Fantasy Tactics read, a square slab
-- of the world sitting in the air rather than a map running off to a
-- horizon. A square cut with a HARD edge, because a flat world is a
-- thing with sides and the sides are what say so.
--
-- V-CURVE is what changes its shape. With the bend on, the world is not
-- flat any more -- it is a little globe curling away over its own
-- horizon -- and a square cut through a globe is a lie about what is
-- being looked at. So the box becomes a BALL, and its rim becomes a
-- GRADIENT that dissolves into the sky rather than an edge that
-- guillotines it. One click of the left stick (which throws V-CURVE --
-- see lib/VR) swaps between the two readings of the same model.
--
-- A staged fight ignores both and cuts a vertical PILLAR about the
-- arena, which lifts the fight out of the map as a floating disc.
--
-- (A BASE was built under all this once -- the ground extruded a tile
-- deep, cut to the viewport's shape, wearing Mt Moon's cave floor down
-- its sides -- and it was REMOVED at the user's request. The cut ends at
-- the ground plane now; don't put a plinth back under it.)
--
-- THE GRIP. Squeeze one and the model follows that hand through the
-- room; squeeze both and it turns with them and the viewport resizes
-- to whatever you open your hands to. All of it is arithmetic on the
-- XR-to-world mapping lib/VRRig already had (an anchor, a yaw and a
-- scale), so nothing about the world's own geometry knows this is
-- happening.
--
-- DIORAMA-MR is the same mode with the background keyed pure green, for
-- a mixed-reality capture that composites the model into the room the
-- player is actually standing in.
--
-- Nothing here reaches the flat screen: every field is set by lib/VR for
-- the length of one headset frame and cleared with the session.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Diorama = {}
-- ------- the viewport
--
-- The half-size at rest, as a fraction of the view height the flat screen
-- frames. Sized off the VIEW rather than fixed in world pixels so the zoom
-- rows keep meaning what they mean -- a zoomed-in rung frames less world
-- and gets a smaller model, exactly as it frames a smaller picture.
--
-- The BOX takes half of it, so the square is exactly the view the standard
-- rung would have shown, edge to edge. The BALL takes rather more: a ball
-- inscribed in that square holds noticeably less world (its corners are
-- the four biggest pieces of it), and the point of the V-CURVE throw is to
-- see the same model curl, not to lose a quarter of it.
Diorama.BOX_FRAC = 0.5
Diorama.BALL_FRAC = 0.62
-- How far the grips may open and close it, as a multiplier on that.
Diorama.SCALE_MIN = 0.3
Diorama.SCALE_MAX = 4
-- The rim under V-CURVE, as a fraction of the radius: where the world
-- starts fading and where it has finished. Wide enough to read as a
-- dissolve rather than an edge, narrow enough that the middle of the model
-- is solid. The BOX has no fade at all -- see fadeFor.
Diorama.FADE_FRAC = 0.16
-- The staged fight's disc: the arena's own half-length plus an apron, in
-- world pixels (a map cell is 16). The two mons stand three cells apart,
-- so this is a disc about seven cells across -- the fight, the ground it
-- is fought on, and nothing else.
Diorama.ARENA_APRON = 32
-- ------- what the live frame is
--
-- All three set by lib/VR for the length of one headset frame, and by
-- nothing else. `on` is the whole mode's gate; VoxelScene reads it once
-- per frame and every diorama-shaped thing hangs off that read.
Diorama.on = false
Diorama.keyed = false
Diorama.cull = nil -- { x, y, z, r, invFade, kind }
-- Chroma green, and PURE green deliberately: a keyer wants the one colour
-- nothing in the picture can accidentally be, and no palette this mod can
-- paint the world in reaches 0,255,0.
Diorama.KEY_COLOR = { 0, 1, 0 }
-- ------- what the grips have done to it
--
-- Kept across frames (this is where the model IS, as far as the player is
-- concerned) and cleared only when the session ends. `offset` is in LOCAL
-- metres and rides the mapping's anchor, `yaw` turns the mapping, `zoom`
-- multiplies the viewport's radius.
Diorama.offset = { 0, 0, 0 }
Diorama.yaw = 0
Diorama.zoom = 1
function Diorama.reset()
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
Diorama.offset = { 0, 0, 0 }
Diorama.yaw, Diorama.zoom = 0, 1
Diorama.release()
end
-- ------- which way a staged fight lies on the table
--
-- The disc's bearing while a battle is up: the player's own hand-turn, with
-- the ARENA's quarter turn taken back out of it.
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
-- the promise that field makes everywhere else is that turning it changes the
-- GROUND under the fight and never the fight itself -- the two Pokemon land
-- on the same marks, seen the same way round. Every other camera keeps that
-- promise by construction: the flat shot and the standard VR mount are both
-- built from BattleCam's eye, which turns with the arena, so the composition
-- follows it round.
--
-- This one is not built from that eye. It is a disc of map lifted onto the
-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
-- staged on a turned arena arrived on the table lying across the head that
-- was looking at it, while the same fight on an unturned one faced properly.
-- Same fight, same composition everywhere else, sideways here.
--
-- So the turn comes back out. Subtracted, matching the sign the standard
-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
-- seats.
--
-- The hand-turn stays on top of it, because that is the player moving the
-- model and is theirs to keep.
function Diorama.battleYaw(arena)
local turn = (arena and arena.turn) or 0
if turn == 0 then return Diorama.yaw end
local yaw = Diorama.yaw - math.rad(turn)
-- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
-- care which way round it came
return (yaw + math.pi) % (2 * math.pi) - math.pi
end
-- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is
-- not a diorama mode closes it.
function Diorama.begin(mode)
Diorama.on = (mode == "diorama" or mode == "diorama-mr")
Diorama.keyed = Diorama.on and mode == "diorama-mr"
if not Diorama.on then Diorama.cull = nil end
return Diorama.on
end
function Diorama.stop()
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
end
-- What the world's background must be cleared to, or nil to leave the sky
-- alone. Only ever a colour in DIORAMA-MR, and only while a frame is open.
function Diorama.keyColor()
if not (Diorama.on and Diorama.keyed) then return nil end
return Diorama.KEY_COLOR
end
-- ------- the viewport, as the shaders take it
--
-- `kind` is the shader's own switch: 0 no cut, 1 the box, 2 the ball, 3
-- the fight's pillar. `invFade` is one over the fade band in world pixels,
-- so the rim is a single multiply out there -- and a hard edge is simply a
-- band under a pixel wide, which costs the shader no branch of its own.
Diorama.BOX = 1
Diorama.BALL = 2
Diorama.PILLAR = 3
-- The half-size the viewport stands at right now, for a view `vh` world
-- pixels tall -- the flat framing this rung would have shown -- and for
-- the shape it is currently in.
function Diorama.radius(vh, curved)
local frac = curved and Diorama.BALL_FRAC or Diorama.BOX_FRAC
return math.max(24, (vh or 288) * frac * Diorama.zoom)
end
-- Whether the world is BENT right now, which is the whole of what decides
-- the viewport's shape: a square cut suits a flat slab of map, and a
-- curved world rolling away over its own horizon wants a ball with a
-- dissolve. Asked of the row rather than remembered, so the V-CURVE the
-- stick click throws (and the "7" key, and the OPTIONS row) all reach it.
function Diorama.curved()
local ok, on = pcall(function()
return V.require("WorldCurve").active()
end)
return ok and on or false
end
-- The fade band for a cut of half-size `r`: the curve's dissolve, or a
-- hard edge (band 0) for the box.
function Diorama.fadeFor(r, curved)
if not curved then return 0 end
return math.max(1, r * Diorama.FADE_FRAC)
end
local function volume(kind, x, y, z, r, fade)
return { x = x, y = y, z = z, r = r,
-- The BOX kind is rectangular in the shader, because the flat
-- screen's box is the WINDOW's own footprint and a window is not
-- square (lib/ViewBox). A headset's model has no window to be
-- shaped like, so this one is: the same half-size twice.
rx = r, rz = r,
-- a zero band is a hard edge: half a pixel of ramp, which is
-- one pixel of antialiasing rather than a stair
invFade = 1 / math.max(fade or 0, 0.5), kind = kind }
end
-- The viewport this frame, centred on the world point the model is pinned
-- by: the BOX ordinarily, and the BALL while the world is curved.
function Diorama.viewport(cx, cy, vh)
local curved = Diorama.curved()
local r = Diorama.radius(vh, curved)
Diorama.cull = volume(curved and Diorama.BALL or Diorama.BOX,
cx, 0, cy, r, Diorama.fadeFor(r, curved))
return Diorama.cull
end
-- The staged fight's disc: a vertical pillar about the arena's midpoint,
-- wide enough for both mons and their apron. Vertical means UNBOUNDED --
-- a tree standing on the disc keeps all of its height, which is what
-- makes the cut read as the ground having been lifted out rather than as
-- the world having been sliced through at eye level.
function Diorama.pillar(arena)
if not (arena and arena.mid) then return nil end
local mx, mz = arena.mid[1], arena.mid[2]
local r = Diorama.ARENA_APRON
if arena.player and arena.enemy then
local dx = arena.player[1] - mx
local dz = arena.player[2] - mz
r = r + math.sqrt(dx * dx + dz * dz)
end
-- Round whatever the curve is doing -- a fight is a disc, and a square
-- arena tile floating in the air is not the picture -- and ALWAYS
-- dissolved at the rim, curve or no curve. The box's hard edge is there
-- to say "this is a flat slab of map with sides"; a fight is a thing
-- lifted out of the world and hanging in the air, and a hard edge on it
-- reads as a cookie cutter rather than as a piece of ground.
Diorama.cull = volume(Diorama.PILLAR, mx, 0, mz, r,
Diorama.fadeFor(r, true))
return Diorama.cull
end
-- ------- the grips
--
-- One hand carries the model; two turn it and open the viewport. The
-- gesture is measured as a DELTA per frame rather than from where the
-- squeeze started, so letting go and taking hold again never snaps
-- anything -- the model simply stops following and starts again.
Diorama.GRIP = 0.6 -- squeezed past this counts as holding on
Diorama.SPREAD_MIN = 0.08 -- hands closer than this give no scale
local lastOne = nil -- the carrying hand's position, last frame
local lastMid = nil -- both hands' midpoint
local lastAngle = nil -- and the bearing of the line between them
local lastSpread = nil -- and its length
local function clearGrab()
lastOne, lastMid, lastAngle, lastSpread = nil, nil, nil, nil
end
Diorama.releaseGrab = clearGrab
-- Advance the grab from this frame's controller state (lib/VRXR's table:
-- gripL/gripR in 0..1, handl/handr as { pos, quat } when tracked).
-- Returns true while the model is being held.
function Diorama.gesture(ctl)
if not ctl then
clearGrab()
return false
end
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
local hl = (gl > Diorama.GRIP) and ctl.handl or nil
local hr = (gr > Diorama.GRIP) and ctl.handr or nil
if hl and hr then
lastOne = nil
local lp, rp = hl.pos, hr.pos
local mid = { (lp[1] + rp[1]) / 2, (lp[2] + rp[2]) / 2,
(lp[3] + rp[3]) / 2 }
local dx, dy, dz = rp[1] - lp[1], rp[2] - lp[2], rp[3] - lp[3]
local spread = math.sqrt(dx * dx + dy * dy + dz * dz)
-- the bearing of the line between the hands, in the same convention
-- the mapping's yaw turns through (see VRRig.eyeCamera): atan2 of the
-- x component over the z one, so a hand-over-hand turn and the model's
-- turn are the same number
local angle = math.atan2(dx, dz)
if lastMid then
for i = 1, 3 do
Diorama.offset[i] = Diorama.offset[i] + (mid[i] - lastMid[i])
end
end
if lastAngle then
local d = (angle - lastAngle + math.pi) % (2 * math.pi) - math.pi
Diorama.yaw = (Diorama.yaw + d + math.pi) % (2 * math.pi) - math.pi
end
if lastSpread and lastSpread > Diorama.SPREAD_MIN
and spread > Diorama.SPREAD_MIN then
Diorama.zoom = math.max(Diorama.SCALE_MIN,
math.min(Diorama.SCALE_MAX,
Diorama.zoom * (spread / lastSpread)))
end
lastMid, lastAngle, lastSpread = mid, angle, spread
return true
end
lastMid, lastAngle, lastSpread = nil, nil, nil
local one = hl or hr
if one then
if lastOne then
for i = 1, 3 do
Diorama.offset[i] = Diorama.offset[i] + (one.pos[i] - lastOne[i])
end
end
lastOne = { one.pos[1], one.pos[2], one.pos[3] }
return true
end
lastOne = nil
return false
end
-- Nothing here owns a GPU object any more (the base did, and it is gone --
-- see the header), so this is only the grab's own hand-to-hand state: a
-- window resize or a hot reload should not leave the model following a
-- delta measured against a frame that no longer exists.
function Diorama.release()
clearGrab()
end
Diorama.invalidate = Diorama.release
return Diorama
+98
View File
@@ -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
+886
View File
@@ -0,0 +1,886 @@
-- The air under the canopy: fog, god rays, and what drifts through them.
--
-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest
-- today, any map that adds a line tomorrow): a ground haze the scene shader
-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down
-- through an INVISIBLE canopy hanging above the map's real trees, as if the
-- carved hulls on screen were only the understorey of something taller.
--
-- The rays are not placed geometry. A fullscreen pass marches every
-- pixel's eye ray through the air, stops at the frame's own depth buffer
-- (the same detach-and-read Water runs), and asks two questions of every
-- step of air on the way:
--
-- * the SUN'S question -- the shadow map. Air behind a tree hull is
-- dark air; air in a real gap glows. A trunk stands in a column of
-- its own shade, a character walks through the beams and blocks
-- them, and every shaft on screen agrees with the light already on
-- the floor, because it is read from the same map.
--
-- * the CANOPY'S question -- where this thread of sun pierced the
-- invisible leaf layer. Every step of air on one sun ray shares that
-- point, which is what makes a shaft a SHAFT, and the point samples
-- a wind-blown noise field: the dapple drifts and shivers like
-- leaves moving overhead, opening and closing the beams as it goes.
--
-- The shafts lean along the fixed noon shear, deliberately: a canopy
-- map's rig is pinned to noon (see DayNight.CANOPY), so light that
-- followed the sun's arc would part company with every shadow on the
-- floor. What follows the clock is colour and strength -- gold spears of
-- sun by day, silver moon rays after dark, dying back through the
-- twilights -- plus the crew each shift brings: pollen adrift in the
-- day's beams, fireflies once they cool. A forward-scattering phase term
-- brightens the beams for a camera looking up into the light, which is
-- most of what makes them read as light in air rather than paint on it.
--
-- Everything is deterministic: placement and the leaf field are dealt by
-- a seeded xorshift (StadiumFx's generator), motion is a pure function
-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame
-- exactly (see tests/forest_fog_shots). Every shader compiles lazily
-- behind pcall -- nil untried, false unavailable -- and each refusal
-- subtracts only itself: no march without readable depth, no beams
-- without a shadow map, and the fog rides the scene shader whatever
-- happens here.
local V = ...
local DayNight = V.require("DayNight")
local ModSetting = V.require("ModSetting")
local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max
local ForestAtmos = {}
-- The viewport, as both shaders here take it (see Voxel3D.cull: the field
-- is set for a headset's diorama frame and for an orbit rung's window box,
-- and nil for every other, where kind 0 means "no cut"). Read through
-- V.require rather than held as an upvalue because this file loads before
-- Voxel3D on some paths.
local function cullAt()
local c = V.require("Voxel3D").cull
return c and { c.x, c.y, c.z } or { 0, 0, 0 }
end
local function cullShape()
local c = V.require("Voxel3D").cull
return c and { c.r, c.invFade, c.kind } or { 0, 0, 0 }
end
local function cullRect()
local c = V.require("Voxel3D").cull
return c and { c.rx or c.r, c.rz or c.r } or { 0, 0 }
end
-- FULL is the point; LOW halves the march and drops the particles, for
-- hardware that minds a per-pixel loop under 4X supersampling.
--
-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the
-- default. The march needs a depth texture it can READ, and no driver on
-- the phones this runs on has granted one (see newDepth in Voxel3D) --
-- so FULL would be a rung with nothing behind it, which reads as a
-- broken mod rather than a missing feature. LOW there is the haze, the
-- one part of the atmosphere that rides the scene shader and works
-- everywhere. A desktop save opened on a phone stores FULL still;
-- ModSetting's unknown-value fallback lands it on LOW, and putting the
-- save back on the desktop restores the choice.
local function onAndroid()
if not (love and love.system and love.system.getOS) then return false end
local ok, os = pcall(love.system.getOS)
return ok and os == "Android"
end
ForestAtmos.setting = onAndroid()
and ModSetting.new("atmos", "FOREST FX", { "low", "off" },
{ "LOW", "OFF" })
or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" },
{ "FULL", "LOW", "OFF" })
-- the animation clock: ticked by main.lua's always-running update hook,
-- pinnable (frozen = true) so a screenshot driver can hold a frame still
ForestAtmos.time = 0
ForestAtmos.frozen = false
function ForestAtmos.update(dt)
if ForestAtmos.frozen then return end
ForestAtmos.time = ForestAtmos.time + (dt or 0)
end
-- ------- the authored table
--
-- Same shape as BattleArena's: the data file behind a pcall with a false
-- sentinel, and an overrides table a tuning driver can stage a candidate
-- through before anything is written down. `~= nil` on the override,
-- because false is meaningful -- "this map has no atmosphere, whatever
-- the file says".
local authored = nil
local overrides = {}
local function configFor(mapId)
if not mapId then return nil end
local forced = overrides[mapId]
if forced ~= nil then return forced or nil end
if authored == nil then
local ok, list = pcall(V.data, "map_atmosphere")
authored = (ok and type(list) == "table") and list or false
end
if not authored then return nil end
return authored[mapId]
end
ForestAtmos.configFor = configFor
-- ------- caches
--
-- Particle layouts and meshes go stale with the map (map.reloaded, and
-- the pipeline's invalidate); called with no map id this also resets the
-- shader and texture sentinels, which is what a lost GL context needs.
local layoutCache = {}
local meshCache = {}
local shaders = {} -- keyed by variant; nil untried, false refused
local leafTex = nil -- the tiling leaf-dapple field
local rayMesh = nil -- the fullscreen ray-fan quad, re-aimed per draw
-- Every bail here is deliberate and silent on screen -- a missing piece
-- subtracts itself, never the frame -- but "the beams are off" and "the
-- beams are off BECAUSE ..." are different debugging days. Each reason
-- is said once on the console, the way VR reports a missing runtime.
local said = {}
local function say(key, msg)
if said[key] then return end
said[key] = true
print("[DRAMATIC_SHAPE] atmos: " .. msg)
end
function ForestAtmos.invalidate(mapId)
if mapId then
layoutCache[mapId] = nil
meshCache[mapId] = nil
else
layoutCache, meshCache = {}, {}
shaders = {}
leafTex = nil
rayMesh = nil
end
end
function ForestAtmos.setOverride(mapId, entry)
overrides[mapId] = entry
ForestAtmos.invalidate(mapId)
end
-- ------- the hour's answer
--
-- One ramp, authored per phase and blended with DayNight's own weights,
-- so the fog and the rays can never disagree about what hour it is. The
-- two interact three ways: the fog colour leans toward the ray colour
-- (noon warms the haze, midnight silvers it), the rays scale with the
-- fog's density (a beam IS lit fog -- less medium, less beam), and the
-- march accumulates through the same density the surfaces sink into.
ForestAtmos.RAMP = {
day = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 },
alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 },
golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 },
alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 },
dawn = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 },
alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 },
dusk = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 },
alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 },
violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 },
alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 },
night = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 },
alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
}
-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
-- and Voxel3D.fog should be left nil.
function ForestAtmos.frame(map, t)
if ForestAtmos.setting:get() == "off" then return nil end
local cfg = configFor(map and map.id)
if not cfg then return nil end
local mix = DayNight.mix(t or DayNight.time())
local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
local alpha, dens, motes, flies = 0, 0, 0, 0
for name, w in pairs(mix) do
local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w
alpha = alpha + p.alpha * w
dens = dens + p.density * w
motes = motes + p.motes * w
flies = flies + p.flies * w
end
-- the haze takes on a little of the light standing in it
local LEAN = 0.15
fr = fr + (rr - fr) * LEAN
fg = fg + (rg - fg) * LEAN
fb = fb + (rb - fb) * LEAN
local base = cfg.fog or {}
local rays = cfg.rays or {}
return {
-- in exactly the shape Voxel3D.fog takes, so callers assign it whole
fog = { color = { fr, fg, fb },
density = (base.density or 0) * dens,
start = base.start or 0,
heightK = base.heightK or 0 },
rayColor = { rr, rg, rb },
-- a beam is scattered fog: less medium, less beam
rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)),
rayStrength = rays.strength or 12,
rayReach = rays.reach or 380,
moteLevel = motes,
fireflyLevel = flies,
cfg = cfg,
}
end
-- ------- deterministic noise
--
-- The same written-out xorshift StadiumFx runs (see the note there on why
-- not LuaJIT's `bit`): the particle deal and the leaf field must come out
-- identical on every machine and every visit.
local function bxor32(a, b)
local r, p = 0, 1
for _ = 1, 32 do
local x, y = a % 2, b % 2
if x ~= y then r = r + p end
a, b, p = floor(a / 2), floor(b / 2), p * 2
end
return r
end
local Rng = {}
Rng.__index = Rng
local function newRng(seed)
local s = seed % 0x100000000
if s == 0 then s = 0x9E3779B9 end
return setmetatable({ s = s }, Rng)
end
function Rng:next()
local x = self.s
x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13)
x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17
x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5)
self.s = x % 0x100000000
return self.s
end
function Rng:unit()
return self:next() / 0x100000000
end
-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles
local function lattice(rng, w, h)
local g = {}
for y = 1, h do
local row = {}
for x = 1, w do row[x] = rng:unit() end
g[y] = row
end
return g
end
local function smoothstep01(t)
return t * t * (3 - 2 * t)
end
local function torus(grid, w, h, x, y)
local x0, y0 = floor(x), floor(y)
local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0)
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
x0, y0 = x0 % w, y0 % h
local a = grid[y0 + 1][x0 + 1]
local b = grid[y0 + 1][x1 + 1]
local c = grid[y1 + 1][x0 + 1]
local d = grid[y1 + 1][x1 + 1]
return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy
end
-- ------- the leaf field
--
-- One small tiling texture of three-octave value noise, generated once
-- from a fixed seed: the pattern of the unseen foliage. The shader reads
-- it at two drifting, differently-scaled offsets and thresholds the sum,
-- so the pools of light between the leaves slide, open and close -- the
-- movement is the WIND's, all in the sampling; the cloth itself never
-- changes, which is what keeps a pinned frame reproducible.
local function leafTexture()
if leafTex ~= nil then return leafTex or nil end
if not (love and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then
leafTex = false
return nil
end
local ok, tex = pcall(function()
local N = 128
local rng = newRng(0x1EAF)
local g1 = lattice(rng, 8, 8)
local g2 = lattice(rng, 16, 16)
local g3 = lattice(rng, 32, 32)
local img = love.image.newImageData(N, N)
for y = 0, N - 1 do
local v = y / N
for x = 0, N - 1 do
local u = x / N
local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5
+ torus(g2, 16, 16, u * 16, v * 16) * 0.3
+ torus(g3, 32, 32, u * 32, v * 32) * 0.2
img:setPixel(x, y, n, n, n, 1)
end
end
local t = love.graphics.newImage(img)
t:setWrap("repeat", "repeat")
t:setFilter("linear", "linear")
return t
end)
leafTex = (ok and tex) or false
return leafTex or nil
end
-- ------- placement (the particles; the light places itself)
local MARGIN = 24 -- keep off the map's edge, world px
function ForestAtmos.layout(cfg, w, h)
local rng = newRng((cfg.seed or 0x51D))
local canopy = cfg.canopyY or 56
local motes = {}
for _ = 1, (cfg.motes and cfg.motes.count) or 0 do
motes[#motes + 1] = {
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
y = 3 + rng:unit() * max(canopy - 11, 8),
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
phase = rng:unit() * 6.2832,
rate = 0.5 + rng:unit(),
}
end
local flies = {}
for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do
flies[#flies + 1] = {
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
y = 3 + rng:unit() * 12,
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
phase = rng:unit() * 6.2832,
rate = 0.5 + rng:unit(),
}
end
return { motes = motes, flies = flies }
end
-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
-- for world pixels (the same arithmetic Structures runs in tiles).
local function layoutFor(map)
local hit = layoutCache[map.id]
if hit ~= nil then return hit or nil end
local cfg = configFor(map.id)
if not cfg then
layoutCache[map.id] = false
return nil
end
local def = map.def or {}
local L = ForestAtmos.layout(cfg, (def.width or 16) * 32,
(def.height or 16) * 32)
layoutCache[map.id] = L
return L
end
ForestAtmos.layoutFor = layoutFor
-- ------- the volumetric march
--
-- A fullscreen quad whose four corners carry the camera's own frustum
-- rays; the varying interpolates them into a world ray per pixel. The
-- pixel stage walks that ray to the depth buffer's surface, and every
-- step of air on the way is lit or not by the shadow map and the leaf
-- field, accumulated through the same haze the surfaces sink into.
--
-- Conventions copied from Water's march: the ray walks the FLAT world
-- (the space it is straight in) and every depth compare bends the sample
-- first, by the same displacement the vertex stage applies -- so the
-- march reads the depth buffer it actually has. The shadow lookup stays
-- flat, exactly like the scene shader's own vSun. STEPS is spliced into
-- the source rather than sent (the LOW rung is a second compile), and
-- there are no uniform arrays anywhere -- see the note in Sky about the
-- Android driver that reads them as zero.
local RAY_SHADER = [[
varying vec3 vRay;
#ifdef VERTEX
attribute vec3 RayDir;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vRay = RayDir;
return transform_projection * vertex_position;
}
#endif
#ifdef PIXEL
uniform Image depthTex; // the frame's own depth, detached to read
uniform Image sunMap; // the sun's answer (see ShadowMap)
uniform Image leafTex; // the unseen foliage, tiling
uniform mat4 vp;
uniform mat4 sunVP;
uniform float sunBias;
uniform vec3 eye;
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
uniform vec2 screen; // canvas size, for the pixel's own uv
uniform vec4 fogW; // density, heightK, canopyY, fadeTo
uniform vec3 shear; // the noon shear kx, kz; z = reach
uniform vec3 rayColor;
uniform float strength;
uniform vec3 sunward; // unit, toward the unseen sun
uniform vec2 wind; // leaf-field drift, uv per second
uniform float time;
// the viewport, as the scene shader takes it (see Voxel3D): air outside
// the model is not air, so a sample out there contributes nothing and
// the beams end with the world they fall through
uniform vec3 cullAt;
uniform vec3 cullShape;
uniform vec2 cullRect; // the box's half-extents in x and z
float dioramaCull(vec3 p) {
if (cullShape.z <= 0.5) return 1.0;
vec3 cd = p - cullAt;
float inside;
if (cullShape.z < 1.5) { // the box
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
} else if (cullShape.z < 2.5) {
inside = cullShape.x - length(cd); // the ball
} else {
inside = cullShape.x - length(cd.xz); // the fight's pillar
}
return clamp(inside * cullShape.y, 0.0, 1.0);
}
float sunDepth(vec2 uv) {
vec4 c = Texel(sunMap, uv);
return c.r + c.g * (1.0 / 255.0);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 uv = sc / screen;
float sceneD = Texel(depthTex, uv).r;
vec3 dir = normalize(vRay);
// Spend every sample where a sample can glow. Above the canopy no
// beam exists, and below the floor there is no air at all -- so the
// march runs from where this ray first dips under the leaves to
// where it would pass the ground, however long or short that
// stretch is. From the orbit camera that is the last few dozen
// pixels of a mostly-vertical ray, and dividing the WHOLE reach by
// the step count there starved the beams to nothing.
float t0 = 0.0;
if (eye.y > fogW.z) {
if (dir.y >= -0.01) return vec4(0.0);
t0 = (eye.y - fogW.z) / -dir.y;
}
float tEnd = shear.z;
if (dir.y < -0.01) {
tEnd = min(tEnd, (eye.y + 8.0) / -dir.y);
}
if (tEnd <= t0) return vec4(0.0);
// interleaved gradient noise staggers neighbouring pixels' steps,
// which is what turns 20-odd samples into a smooth volume instead
// of an onion of banded slices
float jitter = fract(52.9829189
* fract(dot(sc, vec2(0.06711056, 0.00583715))));
float dt = (tEnd - t0) / float(STEPS);
// HALF the fog's own extinction, on the way in and per step: the
// full rate is what the surfaces sink by, and beams that obeyed it
// too died before the orbit camera ever saw them. Half keeps the
// depth cue and leaves the light alive.
float trans = exp(-fogW.x * 0.5 * t0);
float acc = 0.0;
for (int i = 0; i < STEPS; i++) {
float t = t0 + (float(i) + jitter) * dt;
vec3 p = eye + dir * t;
// stop at the surface: bend the sample the way the geometry bent
vec2 cd = p.xz - curve.xy;
vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0);
if (c.w <= 1e-6) break;
if (c.z / c.w * 0.5 + 0.5 > sceneD) break;
if (p.y < fogW.z) {
// the sun's question: is this air behind a tree? Outside the
// frustum nothing was recorded and the air counts as lit, eased
// at the rim exactly like the scene shader's shadows
float lit = 1.0;
vec3 su = (sunVP * vec4(p, 1.0)).xyz;
if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0
&& su.z < 1.0) {
vec2 e2 = min(su.xy, 1.0 - su.xy);
float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y));
lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge);
}
// the canopy's question: where did this thread of light pierce
// the leaves? Every step of air on one sun ray shares the
// answer -- that shared point is what makes a shaft a shaft --
// and the two drifting reads of the field are the wind moving
// the foliage overhead, opening and closing the beams
float up = fogW.z - p.y;
vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0);
float n = Texel(leafTex, gap + wind * time).r * 0.65
+ Texel(leafTex, gap * 2.3 - wind * (time * 0.7)
+ vec2(0.37, 0.61)).r * 0.35;
float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n);
// the beam fades IN below the invisible canopy, thins with
// altitude like the haze it is made of, and kisses the floor
float y = max(p.y, 0.0);
float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0);
float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0);
float dens = fogW.x * exp(-y * fogW.y);
acc += trans * lit * dapple * fadeIn * foot * dens * dt
* dioramaCull(p);
}
trans *= exp(-fogW.x * 0.5 * dt);
}
// forward scattering: beams bloom for a camera looking up into the
// light, which is most of what makes them read as light IN air
float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0);
return vec4(rayColor * (acc * strength * phase), 1.0) * color;
}
#endif
]]
local function rayShaderFor(steps)
local key = "ray" .. steps
local s = shaders[key]
if s ~= nil then return s or nil end
if not (love and love.graphics and love.graphics.newShader) then
shaders[key] = false
return nil
end
local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER
local ok, sh = pcall(love.graphics.newShader, src)
if not ok then
say(key, "ray shader refused -- beams off, fog stays: "
.. tostring(sh))
end
shaders[key] = (ok and sh) or false
return shaders[key] or nil
end
local RAY_FORMAT = {
{ "VertexPosition", "float", 2 },
{ "RayDir", "float", 3 },
}
-- The camera's frustum corners, from the same fields every pass sets:
-- eye, focus, fovY, and the placed camera's up when there is one (VR
-- eyes roll; the orbit never does). Interpolating a corner ray across
-- the quad IS the standard reconstruction for a perspective camera, so
-- this works identically for the orbit, first person and both eyes.
local function rayQuad(Voxel3D, w, h)
local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY
if not (e and fo and fov) then return nil end
local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
local fl = sqrt(fx * fx + fy * fy + fz * fz)
if fl < 1e-6 then return nil end
fx, fy, fz = fx / fl, fy / fl, fz / fl
local cam = Voxel3D.camera
local up = (cam and cam.up) or { 0, 1, 0 }
-- right = forward x up, then a true up perpendicular to both
local rx = fy * up[3] - fz * up[2]
local ry = fz * up[1] - fx * up[3]
local rz = fx * up[2] - fy * up[1]
local rl = sqrt(rx * rx + ry * ry + rz * rz)
if rl < 1e-6 then return nil end
rx, ry, rz = rx / rl, ry / rl, rz / rl
local ux = ry * fz - rz * fy
local uy = rz * fx - rx * fz
local uz = rx * fy - ry * fx
local hh = math.tan(fov * 0.5)
local hw = hh * (w / h)
-- canvas row 0 is the TOP of the frame, which is the +up corner
local function corner(su, sv)
return fx + rx * hw * su + ux * hh * sv,
fy + ry * hw * su + uy * hh * sv,
fz + rz * hw * su + uz * hh * sv
end
local x0, y0, z0 = corner(-1, 1)
local x1, y1, z1 = corner(1, 1)
local x2, y2, z2 = corner(1, -1)
local x3, y3, z3 = corner(-1, -1)
local verts = {
{ 0, 0, x0, y0, z0 },
{ w, 0, x1, y1, z1 },
{ w, h, x2, y2, z2 },
{ 0, h, x3, y3, z3 },
}
if not rayMesh then
local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts,
"fan", "stream")
rayMesh = ok and mesh or nil
return rayMesh
end
local ok = pcall(rayMesh.setVertices, rayMesh, verts)
return ok and rayMesh or nil
end
-- ------- the particles
local PART_SHADER = [[
varying vec2 vCorner;
varying float vGlow;
#ifdef VERTEX
uniform mat4 vp;
uniform vec3 curve;
uniform vec3 cullAt; // the viewport (see Voxel3D.cull): a mote
uniform vec3 cullShape; // outside the model is not in the air
uniform vec2 cullRect; // the box's half-extents in x and z
uniform vec3 axisR; // the camera's right, world space
uniform vec3 axisU; // and its up: the billboard's own frame
uniform float time;
uniform float size;
uniform vec2 sway; // wander amplitude: horizontal, vertical
uniform float blinky; // 0 = steady motes, 1 = blinking fireflies
attribute vec4 AtmosData; // corner x, corner y, phase, rate
vec4 position(mat4 transform_projection, vec4 vertex_position) {
float ph = AtmosData.z;
float rt = AtmosData.w;
float t = time * (0.5 + rt);
// bounded wander only -- three incommensurate sines, so nothing ever
// walks off the map or needs a CPU tick to bring it home
vec3 base = vertex_position.xyz + vec3(
sin(t * 0.23 + ph) * sway.x,
sin(t * 0.17 + ph * 2.7) * sway.y,
cos(t * 0.19 + ph * 1.3) * sway.x);
float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0);
vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky);
// a whole mote at once: these are points, so the rim can dim them
// rather than having to cut one in half
if (cullShape.z > 0.5) {
vec3 cd = base - cullAt;
float inside;
if (cullShape.z < 1.5) {
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
} else if (cullShape.z < 2.5) {
inside = cullShape.x - length(cd);
} else {
inside = cullShape.x - length(cd.xz);
}
vGlow *= clamp(inside * cullShape.y, 0.0, 1.0);
}
vCorner = AtmosData.xy;
vec4 w = vec4(base + axisR * (AtmosData.x * size)
+ axisU * (AtmosData.y * size), 1.0);
if (curve.z > 0.0) {
vec2 cd = w.xz - curve.xy;
w.y -= dot(cd, cd) * curve.z;
}
return vp * w;
}
#endif
#ifdef PIXEL
uniform vec3 dotColor;
uniform float level;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float d = dot(vCorner, vCorner);
float glow = max(0.0, 1.0 - d);
glow *= glow;
return vec4(dotColor, glow * level * vGlow) * color;
}
#endif
]]
local function partShader()
local s = shaders.part
if s ~= nil then return s or nil end
if not (love and love.graphics and love.graphics.newShader) then
shaders.part = false
return nil
end
local ok, sh = pcall(love.graphics.newShader, PART_SHADER)
shaders.part = (ok and sh) or false
return shaders.part or nil
end
local PART_FORMAT = {
{ "VertexPosition", "float", 3 },
{ "AtmosData", "float", 4 },
}
local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } }
local function pushQuad(map, n)
local b = n * 4
map[#map + 1] = b + 1
map[#map + 1] = b + 2
map[#map + 1] = b + 3
map[#map + 1] = b + 1
map[#map + 1] = b + 3
map[#map + 1] = b + 4
end
local function buildPartMesh(points)
if #points == 0 then return nil end
local verts, indices = {}, {}
for i = 1, #points do
local p = points[i]
for c = 1, 4 do
verts[#verts + 1] = { p.x, p.y, p.z,
CORNERS[c][1], CORNERS[c][2], p.phase, p.rate }
end
pushQuad(indices, i - 1)
end
local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts,
"triangles", "static")
if not ok then return nil end
pcall(mesh.setVertexMap, mesh, indices)
return mesh
end
local function meshesFor(map, L)
local hit = meshCache[map.id]
if hit then return hit end
local M = {
motes = buildPartMesh(L.motes),
flies = buildPartMesh(L.flies),
}
meshCache[map.id] = M
return M
end
local MOTE_COLOR = { 1.0, 0.96, 0.78 }
local FLY_COLOR = { 0.72, 1.0, 0.45 }
-- The billboard frame: the camera's own right and up, from the same
-- fields every pass sets (per VR eye too -- drawScene runs per eye and
-- reads the eye's camera). Degenerate looks answer nil and the
-- particles sit this one out.
local function billboardAxes(Voxel3D)
local e, fo = Voxel3D.eye, Voxel3D.focus
if not (e and fo) then return nil end
local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
local ll = sqrt(lx * lx + ly * ly + lz * lz)
if ll < 1e-6 then return nil end
lx, ly, lz = lx / ll, ly / ll, lz / ll
local rx, rz = -lz, lx
local rl = sqrt(rx * rx + rz * rz)
if rl < 1e-4 then return nil end
rx, rz = rx / rl, rz / rl
return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly }
end
-- ------- the draw
--
-- Inside the scene pass, in VoxelScene's prop slot. The march borrows
-- the frame's depth through Voxel3D.beginWater -- the same detach Water
-- runs -- and hand-tests every step against it, so the pass itself needs
-- no depth attachment; the particles come after, depth-tested additive
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
-- refused shader, no readable depth, no shadow map -- subtracts only
-- itself.
function ForestAtmos.draw(map)
local rung = ForestAtmos.setting:get()
if rung == "off" then return end
local f = ForestAtmos.frame(map)
if not f then return end
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
if f.rayAlpha > 0.01 then
if not Voxel3D.depthReadable() then
say("depth", "no readable depth this frame -- beams off, fog stays")
return
end
-- no beams without the sun's own pass: uvVP is only the world -> map
-- transform while a shadow map is actually standing
local sunTex = ShadowMap.active() and ShadowMap.texture()
if not sunTex then
say("sun", "no shadow map standing -- beams off, fog stays")
end
local leaf = leafTexture()
if not leaf then
say("leaf", "leaf field would not build -- beams off, fog stays")
end
local sh = rayShaderFor(rung == "low" and 12 or 24)
local w, h = Voxel3D.size()
local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h)
if sh and sunTex and leaf and w and not quad then
say("quad", "no camera frame for the ray fan -- beams off")
end
if quad then
local _, depth = Voxel3D.beginWater(nil)
if depth then
say("on", "volumetric beams running")
local kx, kz = DayNight.shearAt(DayNight.T.day)
local kl = sqrt(kx * kx + kz * kz + 1)
love.graphics.setBlendMode("add", "alphamultiply")
love.graphics.setShader(sh)
pcall(sh.send, sh, "depthTex", depth)
pcall(sh.send, sh, "sunMap", sunTex)
pcall(sh.send, sh, "leafTex", leaf)
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP)
pcall(sh.send, sh, "sunBias", ShadowMap.bias)
pcall(sh.send, sh, "eye", Voxel3D.eye)
pcall(sh.send, sh, "curve",
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 })
pcall(sh.send, sh, "cullAt", cullAt())
pcall(sh.send, sh, "cullShape", cullShape())
pcall(sh.send, sh, "cullRect", cullRect())
pcall(sh.send, sh, "screen", { w, h })
pcall(sh.send, sh, "fogW",
{ f.fog.density, f.fog.heightK,
f.cfg.canopyY or 56, f.cfg.fadeTo or 28 })
pcall(sh.send, sh, "shear", { kx, kz, f.rayReach })
pcall(sh.send, sh, "rayColor", f.rayColor)
pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha)
pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl })
pcall(sh.send, sh, "wind", { 0.016, 0.009 })
pcall(sh.send, sh, "time", ForestAtmos.time)
pcall(love.graphics.draw, quad)
love.graphics.setShader()
love.graphics.setBlendMode("alpha")
end
Voxel3D.endWater()
end
end
if rung == "full" then
local L = layoutFor(map)
local M = L and meshesFor(map, L)
local psh = partShader()
local axisR, axisU = billboardAxes(Voxel3D)
if M and psh and axisR then
Voxel3D.blend("add")
if Voxel3D.beginEffect(psh) then
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
pcall(psh.send, psh, "curve",
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 })
pcall(psh.send, psh, "cullAt", cullAt())
pcall(psh.send, psh, "cullShape", cullShape())
pcall(psh.send, psh, "cullRect", cullRect())
pcall(psh.send, psh, "axisR", axisR)
pcall(psh.send, psh, "axisU", axisU)
pcall(psh.send, psh, "time", ForestAtmos.time)
if M.motes and f.moteLevel > 0.02 then
pcall(psh.send, psh, "size", 1.4)
pcall(psh.send, psh, "sway", { 5, 2.5 })
pcall(psh.send, psh, "blinky", 0)
pcall(psh.send, psh, "dotColor", MOTE_COLOR)
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
pcall(love.graphics.draw, M.motes)
end
if M.flies and f.fireflyLevel > 0.02 then
pcall(psh.send, psh, "size", 1.6)
pcall(psh.send, psh, "sway", { 10, 4 })
pcall(psh.send, psh, "blinky", 1)
pcall(psh.send, psh, "dotColor", FLY_COLOR)
pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
pcall(love.graphics.draw, M.flies)
end
Voxel3D.endEffect()
end
Voxel3D.blend(nil)
end
end
end
return ForestAtmos
+460
View File
@@ -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
+8
View File
@@ -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
+83 -4
View File
@@ -47,6 +47,42 @@ local function indexOf(self, value)
return 1
end
-- ------- rungs that are not always there
--
-- A ladder may carry a rung that cannot be selected right now -- STADIUM
-- needs models built out of a ROM the player supplies, and until that has
-- happened there is nothing behind the option. `gate` is asked per rung and
-- decides whether it exists at all this frame.
--
-- Skipped rather than shown-and-refused, deliberately. A row that can be
-- cycled onto and then does nothing is indistinguishable from a broken mod;
-- a row that simply has fewer stops reads as the mod not offering something,
-- which is the truth. What the player is missing, and how to get it, is said
-- once in the row's help text instead of implied by a dead setting.
--
-- values[1] is never gated: it is the default and the fallback, so there is
-- always at least one rung to land on.
function ModSetting:setGate(gate)
self.gate = gate
return self
end
function ModSetting:allows(i)
if i == 1 or not self.gate then return true end
local ok, allowed = pcall(self.gate, self.values[i], i)
return (not ok) or allowed and true or false
end
-- How many rungs are live, for a caller that wants to know whether a row is
-- worth showing at all.
function ModSetting:rungs()
local n = 0
for i = 1, #self.values do
if self:allows(i) then n = n + 1 end
end
return n
end
-- What the player left it at last session. Read lazily rather than at load
-- time: the loader fills modOptions before a mod runs, but reading through
-- the API keeps this honest about where the value lives.
@@ -63,7 +99,13 @@ function ModSetting:read()
end
function ModSetting:get()
return self.values[self:read()]
local i = self:read()
-- a rung that was live when it was stored and is not now -- the player
-- moved the ROM, or opened the same save on another machine -- reads as
-- the default rather than as a mode with nothing behind it. The stored
-- value is left alone, so putting the ROM back restores their choice.
if not self:allows(i) then return self.values[1] end
return self.values[i]
end
function ModSetting:level()
@@ -91,8 +133,32 @@ function ModSetting:setIndex(i, game)
return value
end
-- Set by the STORED VALUE rather than by its place on the ladder, for a
-- caller that knows which setting it wants and not where it sits -- a
-- preset, or an assertion. An unrecognised value lands on values[1], the
-- same default indexOf answers everywhere else, so this can never leave a
-- setting holding something the row cannot display.
--
-- Worth having as its own entry point because a ladder's ORDER is not a
-- promise: 3D-BTL grew a third rung in the middle of itself (see
-- OverworldBattle), and every caller that had counted to two would have
-- silently meant something else afterwards.
function ModSetting:setValue(value, game)
return self:setIndex(indexOf(self, value), game)
end
-- Step to the next rung that is actually live, in `dir`. Bounded by the
-- ladder's length so a gate that refuses everything still terminates on
-- values[1], which allows() never gates.
function ModSetting:cycle(game, dir)
return self:setIndex(self:read() + (dir or 1), game)
dir = dir or 1
local n = #self.values
local i = self:read()
for _ = 1, n do
i = ((i + dir - 1) % n + n) % n + 1
if self:allows(i) then break end
end
return self:setIndex(i, game)
end
-- Adopt a value set from somewhere else (the mod manager's settings page,
@@ -102,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()
@@ -109,7 +184,7 @@ function ModSetting:row()
return {
id = "DRAMATIC_SHAPE:" .. self.key,
label = self.label,
value = function() return self_.labels[self_:read()] end,
value = function() return self_:valueLabel() end,
step = function(game, dir)
self_:cycle(game, dir)
return true
@@ -120,7 +195,11 @@ end
-- The row the mod manager's own settings page builds for this mod.
function ModSetting:schema(help)
local choices = {}
for i, v in ipairs(self.values) do choices[i] = { self.labels[i], v } end
-- gated rungs are left off the manager's page too, so the two rows agree
-- about what can be chosen
for i, v in ipairs(self.values) do
if self:allows(i) then choices[#choices + 1] = { self.labels[i], v } end
end
if #self.values == 2 and self.values[1] == false then
return { key = self.key, type = "toggle", label = self.label,
default = self.values[1], help = help }
+300 -85
View File
@@ -60,13 +60,80 @@ if DEBUG == nil or DEBUG == false then DEBUG = nil end
OverworldBattle.KEY = "battles"
OverworldBattle.LABEL = "3D-BTL"
-- On by default: a mod whose headline is "the world in 3D" should not need
-- the player to go and find the switch before the world shows up in a
-- battle. ON is first, so it is also what an unreadable stored value falls
-- back to.
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
OverworldBattle.LABEL,
{ true, false }, { "ON", "OFF" })
-- Five rungs. Two independent choices, laid out as one ladder because they
-- are one question to the player -- WHAT is standing there, and WHERE:
--
-- on the MAP on two DISCS
-- pics 2D-3D A 2D-3D B
-- models STADIUM A STADIUM B
--
-- 2D-3D A the mode this file was written for: the fight is staged on
-- the map and the two Pokemon are the GB's OWN PICS, stood up
-- on their tiles as quads (BattleBillboard).
-- 2D-3D B those same pics on a pair of DISCS against the sky, with no
-- map at all (see lib/StadiumStage.lua). The Game Boy's own
-- framing with the Game Boy's own art, in three dimensions --
-- and, like every B rung, it works everywhere, including the
-- caves and shop floors that have nowhere to stage a fight.
-- STADIUM A the staged fight with the Pokemon Stadium battle models in
-- place of those quads -- skinned, animated, and playing the
-- animation the move being used actually calls for (see
-- lib/Stadium.lua). The world is still the world: the fight
-- happens on real ground, in the map's own weather and light.
-- STADIUM B the models on the discs: both halves swapped at once.
-- OFF the engine's own white battle screen.
--
-- A and B is the STAGE and it is the same stage either way -- the discs do
-- not know what is standing on them and BattleScene draws them off
-- `arena.discs` alone, which is why the second column cost a value in this
-- table and nothing else. The four combinations are all reachable rather
-- than only the diagonal, because a player who cannot use the STADIUM rungs
-- -- no ROM, or a ROM they would rather not go and find -- should still be
-- able to have the disc framing, and because the discs are the answer to
-- "this map has nowhere to fight" whichever art is standing on them.
--
-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and
-- what an unrecognised stored value falls back to, and the stored value for
-- this row has been `true` since the row existed. Keeping `true` at the head
-- means every save written before the later rungs existed reads back as the
-- 2D-3D it was written for, and a mod whose headline is "the world in 3D"
-- still does not need the player to go and find the switch.
--
-- Every other stored value is likewise the one it has always been --
-- "stadium" from before there was a B, "stadiumB" from before there was a
-- flat one -- so no save loses the mode it chose.
--
-- Both STADIUM rungs are GATED on the models existing: the mod ships no
-- Pokemon Stadium data, and until the player's own ROM has been found and
-- built from (StadiumInstall) the row simply has two fewer stops. See
-- ModSetting.setGate for why they are skipped rather than shown and refused.
-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are
-- the game's own art, so it needs nothing the base game did not ship.
OverworldBattle.FLAT_B = "flatB"
OverworldBattle.setting =
ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
{ true, "flatB", "stadium", "stadiumB", false },
{ "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })
:setGate(function(value)
if value ~= "stadium" and value ~= "stadiumB" then return true end
local ok, install = pcall(V.require, "StadiumInstall")
return ok and install and install.available()
end)
-- Whether the fight stands on the two carried DISCS rather than on the map
-- -- the B column above, whichever row of it. Asked by stageFor (what to
-- stage on), wantsFront (whether this map needs an arena at all) and, once
-- the arena carries the answer as `arena.discs`, by BattleScene and
-- VoxelScene for what to draw.
--
-- Read straight off the row rather than through Stadium, because it is a
-- question about the STAGE and half the rungs that answer yes have no
-- Stadium models on them at all.
function OverworldBattle.discs()
local value = OverworldBattle.setting:get()
return (value == OverworldBattle.FLAT_B or value == "stadiumB")
end
-- Whether the VR row is ON -- read lazily, because VR requires modules
-- that sit above this one. While it is, this mode stops being optional:
@@ -83,6 +150,15 @@ function OverworldBattle.enabled()
return OverworldBattle.setting:get() and true or false
end
-- Whether the STADIUM rung is the one selected -- read through Stadium so
-- there is one answer to that question and it lives with the mode it
-- describes. Required lazily: Stadium sits above this file and requires it
-- back (for the row), which a load-time require would deadlock.
function OverworldBattle.stadium()
local ok, stadium = pcall(V.require, "Stadium")
return (ok and stadium and stadium.enabled()) and true or false
end
-- ------- BACK SPRITES: the player's own mon stays on the menu
--
-- The staged shot stands BOTH mons on the map, which is the mode's whole
@@ -173,6 +249,9 @@ function OverworldBattle.wantsFront()
local g = require("src.core.Game")
local ow = g and g.overworld
if not (ow and ow.map and ow.player) then return false end
-- a B rung carries its own stage, so the answer is yes on every map and
-- there is nothing to search or to cache
if OverworldBattle.discs() then return true end
if staged.mapId ~= ow.map.id then
local ok, arena = pcall(BattleArena.find, ow.map,
ow.player.cellX, ow.player.cellY,
@@ -276,6 +355,10 @@ end
function OverworldBattle.textRects(battle)
if not battle or battle.blankForAskName then return {} end
-- a capture session aiming has no text to put in the box and takes it
-- off the frame (see the drawTextArea wrap): no box, no glass under it
local cap = BattleScene.capture
if cap and cap.hideTextBox then return {} end
local r = OverworldBattle.TEXT_RECT
local out = { box = r.box }
if battle.phase == "moveSelect" then
@@ -420,6 +503,30 @@ function OverworldBattle.forceOG(g)
return true
end
-- Where THIS fight stands, on whichever rung is running: the map's own
-- ground, or the pair of discs a B rung carries with it.
--
-- The one place the two columns actually diverge, and it is worth stating
-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a
-- map whose authored entry is a refusal -- and the battle then plays exactly
-- as the vanilla game does. A B rung cannot fail: its stage is not something
-- the map has to have room for, so a fight in the tightest cave in Kanto is
-- staged as readily as one on Route 1.
function OverworldBattle.stageFor(state)
if OverworldBattle.discs() and Voxel3D.available() then
local okStage, arena = pcall(function()
return V.require("StadiumStage").arena(state.map)
end)
if okStage and arena then return arena end
-- the discs could not be built; fall through to the map, which is a
-- worse picture but a real one
end
local okFind, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
return (okFind and arena) or nil
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
@@ -430,10 +537,8 @@ function OverworldBattle.begin(state, battle)
if not (state and state.map and state.player) then return false end
if not Voxel3D.available() then return false end
local ok, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
if not (ok and arena) then return false end
local arena = OverworldBattle.stageFor(state)
if not arena then return false end
-- the fight is staged from here on, so the layout it is composed for is not
-- optional any more (see forceOG)
@@ -443,6 +548,9 @@ function OverworldBattle.begin(state, battle)
armed = false, token = 0 }
cullCast(state)
BattleCam.reset()
-- and, on the STADIUM rung, the pair of models that will stand on this
-- arena's two cells. Declines quietly on any other rung.
pcall(function() V.require("Stadium").begin(arena) end)
return true
end
@@ -473,6 +581,7 @@ function OverworldBattle.finish()
restoreCast()
session = nil
Voxel3D.camera = nil
pcall(function() V.require("Stadium").finish() end)
end
-- ------- per-frame
@@ -489,6 +598,11 @@ end
function OverworldBattle.update(dt)
if not session then return end
-- the shiny arrival sparkle's clock. Ticked here rather than in the draw
-- because a paused or covered frame still draws, and a burst that
-- advanced on draws would stall behind a text box mid-twinkle.
pcall(function() V.require("ShinyFx").update(dt) end)
local g = game()
local top = g and g.stack and g.stack:top()
local ow = g and g.overworld
@@ -510,7 +624,13 @@ function OverworldBattle.update(dt)
-- was solved for (the slow drift aside, which was always there). Polled
-- per frame rather than latched at battle start: the row is reachable
-- from the mod manager's page mid-session.
-- A LET'S GO capture session holds it too: the throw is aimed in this
-- exact framing, and a camera that moved under a ball in flight would
-- bend where the flick was pointed after the fact. (The session also
-- sets BattleCam.still, which is what stops the drift -- see
-- CatchThrow.begin.)
BattleCam.steerable = not OverworldBattle.backPinned()
and not BattleScene.capture
-- the right stick, read as a rate before the rig is built from it: the
-- wheel, the keys, the mouse and a drag all arrive as events and have
-- already landed, but a stick is a HELD position and only a tick can
@@ -524,6 +644,17 @@ function OverworldBattle.update(dt)
-- slice: nothing visible can hitch on them
ChunkMesher.pump(true)
-- The STADIUM models, ahead of the pics, because what they decide is
-- WHICH pics are needed: a side a model is standing on gets no billboard
-- texture rendered for it at all (see Stadium.covers). Posed and skinned
-- here too, once for the frame -- the sun pass, the camera and, in a
-- headset, both eyes all draw the same skinned meshes.
pcall(function()
local host = (session.arena and session.arena.map) or session.state.map
V.require("Stadium").update(dt, session.battle,
BattleScene.groundY(host, session.arena))
end)
-- The mons' textures are rendered HERE, with no canvas bound, for the same
-- reason the scene is: the pics layer binds its own targets, and doing that
-- inside somebody else's frame means putting the frame back afterwards.
@@ -582,15 +713,11 @@ function OverworldBattle.update(dt)
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local ios = isIOS()
local okHud, up = false, false
if not ios then
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
end
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not ios and not okHud and not session.hudWarned then
if not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
@@ -635,6 +762,30 @@ function OverworldBattle.battle()
return session.battle
end
-- The staged fight's arena and floor height, for the capture mode: the
-- foe's world cell is the far end of the throw and the floor is what a
-- short ball bounces on. nil whenever there is nothing staged, which is
-- one of the gates that sends a throw back to the engine's own toss.
function OverworldBattle.arenaInfo()
if not (session and session.arena and not session.broken) then return nil end
local host = session.arena.map or (session.state and session.state.map)
if not host then return nil end
return session.arena, BattleScene.groundY(host, session.arena)
end
-- The foe's rendered pic texture, for the capture mode's ring. The mark
-- BattleScene pins is the CELL's ground point, but a species' art sits
-- wherever the artist drew it in the frame -- a bird hovers half a slot
-- above its own feet row -- and a timing ring belongs on the CREATURE,
-- not on its patch of grass. The capture session reads this canvas back
-- once and centres the ring on the art's opaque box. nil on the STADIUM
-- rungs (the foe is a model, no pic is rendered) and before the first
-- textures pass, both of which the caller treats as "use the heuristic".
function OverworldBattle.enemyTexture()
if not (session and session.textures) then return nil end
return session.textures.enemy
end
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
-- transparent canvas of its own. This is what stands the effects up in
@@ -710,6 +861,9 @@ function OverworldBattle.invalidate()
BattleDOF.invalidate()
BattleHud.invalidate()
BattlePics.invalidate()
-- the STADIUM models hold meshes and textures of this graphics context
-- like everything else here does
pcall(function() V.require("Stadium").invalidate() end)
end
-- ------- the battle screen's background
@@ -865,6 +1019,16 @@ OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
-- Which side is being rendered, or nil. The placement wrappers read it.
local texturing = nil
-- Which side is being rendered into its own canvas right now, or nil.
--
-- Exposed because the shiny tint has two applications -- per side here, and
-- both-sides-at-once on the flat path (ShinyUI.installBattlePics) -- and
-- exactly one of them must run per draw. Asking this is what keeps them
-- from stacking, rather than relying on which module installed first.
function OverworldBattle.texturingSide()
return texturing
end
local texCanvas = {}
local innerPics = nil -- captured by install()
local innerHUDs = nil -- likewise, for the snapped HUD layer
@@ -908,6 +1072,14 @@ local OFF = {
-- feet ended up, in canvas coordinates.
function OverworldBattle.sideTexture(battle, side)
if not (innerPics and battle) then return nil end
-- On the STADIUM rung a side standing a MODEL needs no pic: rendering one
-- anyway would hang a second, flat copy of the same Pokemon on the same
-- cell. Asked per side, so a species with no pack -- or a substitute
-- doll, or the trainer before the send-out -- still comes through here.
local okS, covered = pcall(function()
return V.require("Stadium").covers(battle, side)
end)
if okS and covered then return nil end
if not sideVisible(battle, side) then return nil end
local canvas = texCanvasFor(side)
if not canvas then return nil end
@@ -929,12 +1101,43 @@ function OverworldBattle.sideTexture(battle, side)
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
texturing = side
-- A SHINY on this side, tinted here rather than in ShinyUI's flat-path
-- wrap. This is the one place a pic is rendered for ONE side at a time,
-- so it is the only place the two sides can be tinted differently -- a
-- shiny facing an ordinary mon gets its own colour and leaves the other
-- alone, which the engine's both-sides-at-once pic layer cannot do.
local shinyTint = nil
do
-- NOT when this side is showing a PERSON. Both sides can be holding a
-- trainer pic rather than a Pokemon -- the foe's portrait before the
-- send-out, and the player's own back until "Go!" -- and a shiny is a
-- fact about a Pokemon, not about its owner. Tinting through it turned
-- the player's trainer sprite a different colour for the whole intro,
-- which is what a shiny Pokemon in the party looks like if you do not
-- ask this question. The two tests are the same ones sideTexture already
-- uses to label the finished texture, asked here instead of after.
local person = (side == "enemy"
and battle.showEnemyTrainer and battle.trainerPic)
or (side == "player"
and battle.showPlayerBack and battle.playerBackPic)
if not person then
local battler = (side == "player") and battle.player or battle.enemy
local g2 = game()
shinyTint = battler and V.require("ShinyUI")
.tintFor(battler.mon, g2 and g2.data) or nil
end
end
local ok, err = pcall(function()
g.setCanvas(canvas)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
innerPics(battle, 0, 0, 0)
if shinyTint then
V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
else
innerPics(battle, 0, 0, 0)
end
end)
texturing = nil
@@ -980,12 +1183,26 @@ function OverworldBattle.textures(battle)
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = true, nil
if not OverworldBattle.backPinned() then
-- a LET'S GO capture session empties the player's side the same way
-- BACK SPRITES does: that mon is simply not in this shot, so no pic is
-- rendered for it and no shadow lands under it
local cap = BattleScene.capture
if not OverworldBattle.backPinned()
and not (cap and cap.hidePlayer) then
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
end
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
-- On the STADIUM rung both sides can legitimately have no pic -- the pair
-- of them are models -- and this table must still come back, because it
-- carries the HIT FLASH, and because the VR eye pass uses its presence to
-- decide there is a staged fight to draw at all.
local okStanding, standing = pcall(function()
return V.require("Stadium").standing()
end)
if not (out.enemy or out.player or (okStanding and standing)) then
return nil
end
out.flash = OverworldBattle.flashing(battle)
return out
end
@@ -1008,6 +1225,12 @@ function OverworldBattle.install()
OverworldState.dramaticShapeBattleHook = true
end
-- the STADIUM rung's own four wraps, which drive the models' animations
-- off the fight (see Stadium.install). Idempotent in the same way, and
-- installed whichever rung the row is on: the wraps do nothing at all
-- while no stadium session is live.
pcall(function() V.require("Stadium").install() end)
local BattleState = require("src.battle.BattleState")
if BattleState.dramaticShapeBattleHook then return end
@@ -1097,6 +1320,11 @@ function OverworldBattle.install()
self.letterboxWhite = false
OverworldBattle.drawHudPanels(self)
withoutBackgroundFill(self, innerDraw)
-- the LET'S GO capture overlay -- the timing ring, the ball readout,
-- the grade splash -- drawn last in the same GB frame the engine's
-- own HUD drew in, so it letterboxes and chunks identically
local cap = BattleScene.capture
if cap and cap.drawGB then pcall(cap.drawGB, self) end
end
-- The mons are geometry standing on the map now, drawn in the 3D pass
@@ -1114,6 +1342,10 @@ function OverworldBattle.install()
if not shot then
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
end
-- a capture session shows NO player side at all -- not even the
-- pinned back pic BACK SPRITES would keep on the menu
local cap = BattleScene.capture
if cap and cap.hidePlayer then return end
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
-- under the hour's own light, like everything else in the frame -- see
-- withTint, and the tint BattleScene hands over with the shot.
@@ -1130,18 +1362,20 @@ function OverworldBattle.install()
end
-- The battle's text box and its menus, over the frosted glass laid down for
-- them rather than over their own white paper -- and their ink flipped with
-- the HUD's when the ground under the frame is dark, by the same rule and
-- off the same verdict.
-- them rather than over their own white paper. The INK is Gen 1's own black
-- and stays that way whatever is behind the glass -- the panel's tint is
-- what earns it its contrast (see BattleHud).
local innerText = BattleState.drawTextArea
function BattleState:drawTextArea()
if not self.dramaticShapeShot then return innerText(self) end
if isIOS() then return innerText(self) end
local battle = self
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
withoutBoxFill(battle, innerText)
end)
-- While a capture session is being AIMED the box is empty -- the
-- battle's phase is parked, so there is no message in it -- and it
-- covers the bottom third of the frame, which is exactly the room a
-- throw needs to wind up in. So it comes off entirely for those
-- frames and is back the instant a message has something to say.
local cap = BattleScene.capture
if cap and cap.hideTextBox then return end
return withoutBoxFill(self, innerText)
end
-- Move animations are authored against the pics' fixed slots, and a single
@@ -1234,28 +1468,13 @@ function OverworldBattle.install()
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
-- the colorized pipeline this lands in the grayscale BG canvas, where
-- white IS shade 0 and the zone pass then colours it like every other
-- lightest-shade surface. One rule, both pipelines.
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
-- Normally the HUDs have already been drawn this frame, snapped out to the
-- window's edges and composited into the world image (snapHUDs). Drawing
-- them here as well would show each block twice, once in each place.
if self.dramaticShapeShot and snapped() then return end
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
local battle = self
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
innerHUDs(battle, slide)
end)
return innerHUDs(self, slide)
end
BattleState.dramaticShapeBattleHook = true
@@ -1287,18 +1506,17 @@ end
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
-- answered false, it tints its own greens and reds instead, exactly as it does
-- on the flat path. The glyphs are pure black either way, which is what the
-- flip in BattleHud.layerTexture is measured against.
-- on the flat path.
--
-- Shadowed on the instance for this call only, the way drawZonePass shadows
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
-- class method be found again.
function OverworldBattle.hudTexture(battle, slide, dark)
function OverworldBattle.hudTexture(battle, slide)
if not (innerHUDs and battle) then return nil end
local had = rawget(battle, "colorMode")
battle.colorMode = function() return false end
local ok, layer = pcall(BattleHud.layerTexture,
BattleScene.GB_W, BattleScene.GB_H, dark,
BattleScene.GB_W, BattleScene.GB_H,
function() innerHUDs(battle, slide) end)
battle.colorMode = had
return ok and layer or nil
@@ -1329,24 +1547,20 @@ function OverworldBattle.snapHUDs(battle, shot)
local rects, bandX = OverworldBattle.snapRects(shot)
local enemy, player = OverworldBattle.hudLive(battle, slide)
local live = {}
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
-- and the text box's own glass, on the same pass. It stays in the middle of
-- the frame where the engine draws it -- only the HUDs were snapped out --
-- so its GB rect is mapped into the letterbox rather than to an edge.
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
if not isIOS() then
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
-- corner and black ones on the menu reads as a bug rather than as adaptation.
local dark = BattleHud.verdict(live, shot, true)
-- the box's own ink is flipped where the engine draws it, in the GB frame,
-- so the answer has to outlive this function (see drawHudPanels)
if session then session.dark = dark end
local layer = OverworldBattle.hudTexture(battle, slide, dark)
-- The text box's frost panel normally goes into this same world-canvas pass.
-- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating
-- the large ghost rectangle behind the Pokemon. Keep the box border/text but
-- skip only this frosted backing on iOS.
if not isIOS() then
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
end
end
local layer = OverworldBattle.hudTexture(battle, slide)
if not layer then return false end
local g = love.graphics
@@ -1355,13 +1569,29 @@ function OverworldBattle.snapHUDs(battle, shot)
local ok, err = pcall(function()
g.setCanvas(shot.canvas)
g.setBlendMode("alpha")
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end
g.setColor(1, 1, 1, 1)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
BattleScene.GB_W, BattleScene.GB_H)
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
local x = bandX[side] + band[1] * shot.scale
local targetY = shot.ly + band[2] * shot.scale
if isIOS() then
-- Keep the player's HUD exactly where it currently appears on the
-- right. Only the enemy band needs its mirrored destination corrected.
local y = targetY
if side == "enemy" then
y = shot.ph - targetY - band[4] * shot.scale
end
-- iOS presents this Canvas-to-Canvas HUD texture upside down.
g.draw(layer, quad, x, y, 0,
shot.scale, -shot.scale, 0, band[4])
else
g.draw(layer, quad, x, targetY, 0,
shot.scale, shot.scale)
end
end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
@@ -1380,21 +1610,8 @@ end
-- drawn here, in the GB frame, whichever path laid the glass under it.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
if isIOS() then
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
local rect = OverworldBattle.HUD_RECT
love.graphics.setColor(1, 1, 1, 0.84)
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
love.graphics.setColor(1, 1, 1, 1)
battle.dramaticShapeDark = nil
return
end
if snapped() then
battle.dramaticShapeDark = session and session.dark or nil
return
end
local slide = (battle.introSlide or 0) * 4
@@ -1405,9 +1622,7 @@ function OverworldBattle.drawHudPanels(battle)
if player then live.player = rect.player end
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
if not next(live) then return end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
for _, r in pairs(live) do BattleHud.panel(r, shot) end
end
return OverworldBattle
+594
View File
@@ -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
+178
View File
@@ -0,0 +1,178 @@
-- SELECT on a row explains what it does.
--
-- ------- why this exists at all
--
-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
-- into the schema handed to the mod manager (ModSetting:schema takes it), it
-- has been written and kept up to date beside every row in main.lua's
-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
-- menu, whose row is a label and a value and has no room for a third thing;
-- not the mod manager's own page, which renders the same two lines. It was
-- authored, structured, accurate prose sitting in a field with no reader.
--
-- So it gets one. A row on this mod's menus says what it IS on one line and
-- what it is SET TO on the next, and SELECT says what that means -- which is
-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
-- characters however the label is worded.
--
-- SELECT rather than a button that already does something: A steps a setting,
-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
-- SELECT hotkey is installed on OverworldController:handleInput, which only
-- runs while the overworld is the top state, so a menu can have the button
-- without taking anything from the map.
--
-- ------- the shape of it
--
-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
-- ROM's own glyphs and a mod-supplied font theme retextures this along with
-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
-- with the menu still visible above it, which is where this game has put
-- every line of text anybody has ever read in it.
--
-- Sized to what it holds rather than to the screen. Each description is one
-- sentence, so most of these are five or six tiles tall and the row being
-- asked about is still on screen over the top of the box. A sentence long
-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
-- time on the d-pad -- which is a fallback, not the design: the answer to a
-- description that needs scrolling is a shorter description.
-- the mod namespace (see main.lua)
local V = ...
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local PaletteFX = require("src.render.PaletteFX")
local SettingsHelp = {}
SettingsHelp.__index = SettingsHelp
-- NOT opaque: the menu stays drawn underneath, so the row being asked about
-- is still on screen above the box. That is most of why the box is only as
-- tall as it needs to be.
SettingsHelp.isOpaque = false
-- The box spans the screen's twenty tiles and its border owns the outer ring,
-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
-- is kept clear for the more-arrow, which would otherwise land on top of the
-- last character of any line that filled the width.
local COLS = 17
local PEN_X = 8
local SCREEN_ROWS = 18
-- title, plus the body, plus the two border rows
local CHROME_ROWS = 3
-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
-- characters, which is a long sentence and a box two thirds up the screen.
local MAX_LINES = 8
-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
-- show, and this one is the whole point of the screen.
local function wrapped(str, cols)
cols = cols or COLS
local lines, line = {}, nil
for word in tostring(str or ""):gmatch("%S+") do
local try = line and (line .. " " .. word) or word
if #try <= cols then
line = try
else
if line then lines[#lines + 1] = line end
-- a word longer than the line is broken across lines rather than cut;
-- nothing in the help text is that long today, but losing the end of a
-- sentence silently is not a failure mode worth leaving open
while #word > cols do
lines[#lines + 1] = word:sub(1, cols)
word = word:sub(cols + 1)
end
line = word
end
end
if line then lines[#lines + 1] = line end
return lines
end
SettingsHelp.wrapped = wrapped
function SettingsHelp.new(game, title, body)
return setmetatable({
game = game,
title = tostring(title or ""):gsub("%.%.$", ""),
lines = wrapped(body),
top = 0,
}, SettingsHelp)
end
-- How many body lines this box shows: all of them, unless there are more than
-- a box is allowed to be tall.
function SettingsHelp:bodyRows()
return math.min(#self.lines, MAX_LINES)
end
function SettingsHelp:maxTop()
return math.max(0, #self.lines - self:bodyRows())
end
-- Every button that could mean "done" closes it, including SELECT itself --
-- the press that opened the box is the one a player is most likely to reach
-- for to get rid of it. A is in there too: it steps a setting everywhere else
-- on these menus, and stepping one you cannot see would be worse than an
-- extra way out.
local DISMISS = { "a", "b", "start", "select" }
function SettingsHelp:update()
local input = self.game and self.game.input
if not input then return end
local maxTop = self:maxTop()
-- the d-pad only does anything when there is something below the fold; a
-- box showing its whole sentence has nowhere to go and says so by not
-- moving
if input:wasPressed("down") then
self.top = math.min(maxTop, self.top + 1)
return
elseif input:wasPressed("up") then
self.top = math.max(0, self.top - 1)
return
end
for _, btn in ipairs(DISMISS) do
if input:wasPressed(btn) then
local stack = self.game.stack
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if stack and stack:top() == self then stack:pop() end
return
end
end
end
function SettingsHelp:draw()
local body = self:bodyRows()
local th = body + CHROME_ROWS
local ty = SCREEN_ROWS - th -- anchored to the bottom of the screen
Font.drawBox(0, ty, 20, th)
love.graphics.setColor(0, 0, 0, 1)
-- the row's own name, so the box says what it is about even where it covers
-- the row that was asked
Font.draw(self.title, PEN_X, (ty + 1) * 8)
for i = 1, body do
local line = self.lines[self.top + i]
if not line then break end
Font.draw(line, PEN_X, (ty + 1 + i) * 8)
end
-- the same marker the options list uses for "there is more below this", so
-- it means here what it means there
if self.top < self:maxTop() then
Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Game:draw stops at the first state that HAS this method, so without one the
-- box would inherit whatever is underneath -- which is a menu of ours, whose
-- answer happens to be right. Stated anyway: the reason that answer is right
-- is not a property of this screen, and a future menu that paints something
-- of its own would silently repaint this box with it.
function SettingsHelp:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
return SettingsHelp
+464
View File
@@ -0,0 +1,464 @@
-- This mod's settings, in categories, on menus of their own.
--
-- ------- why the flat list had to end
--
-- Every setting used to be spliced straight into the engine's OPTIONS list,
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
-- was four screens of scrolling inside a list that already carried twenty
-- engine rows -- and a player looking for SHADOWS had to know it was in there
-- somewhere, past the wireframe and the horizon bend.
--
-- The engine has no grouping to borrow: a row descriptor is
-- { id, label, value, step, activate } and nothing else. No headers, no
-- sections, no pages. What it DOES have is `activate`, and a state stack that
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
--
-- ------- how the split was chosen
--
-- Not invented here: the mod already sorted its own settings, in the `full`
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
-- take away, and the reasoning written next to each one is always the same
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
-- the diorama FULL is a preset for.
--
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
-- case to disappear under FULL: every child filters itself out and the
-- category goes with them (see rows). PERFORMANCE is the three rows marked
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
-- are the two features that are not about the look at all.
--
-- ------- what did NOT change
--
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
-- row id, so options.lua is byte-identical for a player who upgrades and
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
-- identity a setting has. The hotkeys are untouched too, which is what makes
-- the nesting affordable: a buried row is still one keypress away.
-- the mod namespace (see main.lua)
local V = ...
local OptionRows = require("src.ui.OptionRows")
local PaletteFX = require("src.render.PaletteFX")
local SettingsMenu = {}
SettingsMenu.__index = SettingsMenu
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
-- covered, so there is no reason to pay for drawing it.
SettingsMenu.isOpaque = true
SettingsMenu.ROOT = "root"
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
-- Row ids live in a namespace of their own -- "menu." rather than a setting
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
-- settings rows have carried since the beginning.
function SettingsMenu.id(catId)
return "DRAMATIC_SHAPE:menu." .. catId
end
-- ------- the categories, in menu order
--
-- `summary` is the second line of the category's own row, the way MODS reads
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
-- current rung, which is the thing a player actually wants to know without
-- opening it. Where no single row speaks for the rest, it counts them, which
-- is honest rather than arbitrary.
SettingsMenu.CATEGORIES = {
{ id = "world", label = "3D WORLD..",
help = "The diorama itself: how far the world bends, how much of it is "
.. "drawn, what the water does and what hour it is outdoors." },
{ id = "battles", label = "BATTLES..",
summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
help = "What a fight is drawn over, how it is framed, and how a ball is "
.. "thrown." },
{ id = "perf", label = "PERFORMANCE..",
help = "What the look costs -- the three most expensive things in the "
.. "frame after the geometry itself." },
{ id = "vr", label = "VR..",
summary = function() return V.require("VR").setting:valueLabel() end,
help = "PCVR through OpenXR, and the one comfort setting that belongs to "
.. "the headset alone." },
}
-- ------- help for the rows that are not settings
--
-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
-- next to the row it explains. What is left is the two pipeline rows -- whose
-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
-- and the ROM import, which is an action rather than a setting and has no
-- SETTINGS entry to live in.
local ROW_HELP = {
["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
.. "by a 3D camera, with the numbered rungs its angle in degrees.",
["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
.. "look, sharp across the middle and softening above and below it.",
["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
.. "cartridge that 3D-BTL's STADIUM rungs need.",
}
-- ------- what the menus are built from
--
-- SETTINGS lives in main.lua, next to the help text that goes with each row
-- and the comments explaining every `when` and `full`. It is handed here
-- rather than moved, so this file stays about PRESENTATION and that one stays
-- the single place the mod's settings are declared.
local settings = {}
local pipelineRows = {}
function SettingsMenu.define(list)
settings = list or {}
end
-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
-- the category's out of CATEGORIES, or one of the three above for the rows
-- that have nowhere else to keep it.
--
-- Looked up BY ID rather than hung on the row as a field, because two of
-- these rows are the engine's own tables reused verbatim -- and annotating
-- somebody else's table is how a mod ends up owning a field it never meant
-- to. nil for a row with nothing to say, which SELECT reads as "no box".
function SettingsMenu.helpFor(id)
if ROW_HELP[id] then return ROW_HELP[id] end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
if SettingsMenu.id(cat.id) == id then return cat.help end
end
for _, entry in ipairs(settings) do
if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
end
return nil
end
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
-- .rows), captured by the options hook on its way past and shown here instead
-- of at the top level. Reused verbatim, tables and all: they persist in
-- save.options.pipelines through their own step functions, and rebuilding
-- them here would be a second implementation of a thing the engine already
-- got right.
function SettingsMenu.setPipelineRows(rows)
pipelineRows = rows or {}
end
-- ------- a step here has the same consequences as a step anywhere
--
-- Two of these settings PIN something else when they change: 3D-BTL holds
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
-- pinning on its way past.
--
-- Nothing reruns that hook from in here, so the pin is asked for directly.
-- main.lua supplies it, because WHICH values follow which is a question about
-- the mod's settings and not about the menu they are on.
local onChanged = nil
function SettingsMenu.setOnChanged(fn)
onChanged = fn
end
local function isFull()
local Pipelines = require("src.render.Pipelines")
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
end
-- The one rule that decides whether a setting is on a menu, lifted unchanged
-- from the options hook it used to live in.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. And a row whose own switch is off the table this frame (BACK
-- SPRITES, which needs a staged fight to be about) is left off with it. The
-- mod manager's page carries every one of them either way.
local function offered(entry, full)
return (entry.full or not full) and (not entry.when or entry.when())
end
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
-- no side effects -- so a caller that only wants to know what is on a menu
-- (a test, or the root menu asking whether a category has anything in it)
-- does not have to push a screen to find out.
function SettingsMenu.rows(catId, game)
local full = isFull()
local out = {}
if catId == SettingsMenu.ROOT then
for _, row in ipairs(pipelineRows) do
-- FULL owns the blur exactly as it owns the wireframe and the horizon
-- bend, so T-SHIFT comes off with them
if not (full and row.id == "pipeline:tiltshift") then
out[#out + 1] = row
end
end
-- ------- settings that belong to no category
--
-- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
-- menu next to the pipeline rows. For a setting that is about the GAME
-- rather than about one of the four things the categories are for --
-- SHINY ODDS is the first -- burying it under a heading it does not
-- belong to is worse than the flat list this menu was built to end.
--
-- Above the categories, because these are rows you CHANGE and those are
-- rows you OPEN: everything with a value on it stays together at the top
-- of the screen, and the "..." rows read as the way further in.
for _, entry in ipairs(settings) do
if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
out[#out + 1] = entry[1]:row()
end
end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
local kids = SettingsMenu.rows(cat.id, game)
-- An EMPTY category is not offered. This is the whole of what makes
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
-- special case, just nothing left inside to open.
if kids[1] then
out[#out + 1] = {
id = SettingsMenu.id(cat.id),
label = cat.label,
value = cat.summary
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
activate = function(g)
g.stack:push(SettingsMenu.new(g, cat.id))
end,
}
end
end
-- ------- and the ROM import, last, on the top-level menu
--
-- An ACTION and not a setting: there is no rung to store, nothing for the
-- mod manager's page to persist and nothing to restore on the next boot,
-- so it is appended rather than living in SETTINGS.
--
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
-- and wait while it builds -- and a player who has been told to import a
-- ROM should find the row where the mod begins, not two levels down a
-- category they have no reason to open until it has worked. Last, because
-- the categories are what the menu is FOR.
local ok, importRow = pcall(function()
return V.require("StadiumRomPick").row()
end)
if ok and importRow then out[#out + 1] = importRow end
return out
end
for _, entry in ipairs(settings) do
if entry.cat == catId and offered(entry, full) then
out[#out + 1] = entry[1]:row()
end
end
return out
end
-- ------- red ink for the mod's row on the OPTIONS menu
--
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
-- nobody spends an afternoon on it. Twice over:
--
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
-- MULTIPLICATIVELY. black x red is black.
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
--
-- What actually happens on this screen is that setColor picks a SHADE and the
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
-- The engine does the same thing for the party menu's HP bars
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
SettingsMenu.INK = { 255, 0, 0 }
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
-- the row is the same white as the row above it in all three.
function SettingsMenu.redPalette(data)
local base = PaletteFX.pal(data, "MEWMON")
if not base then return nil end
local out = { base[1], base[2], base[3], base[4] }
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
-- it the ink is the first slot and the paper the last. Put the red where it
-- will land on the INK either way: without this the row draws as a solid
-- red block with white letters cut out of it.
--
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
-- outright and substitute their own, so the row simply draws monochrome --
-- which is correct: the player asked for a screen with no colors in it.
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
return out
end
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
-- black glyphs that would turn red along with the text if the band spanned
-- the whole row.
function SettingsMenu.rowZone(data, slot)
local pal = SettingsMenu.redPalette(data)
if not pal then return nil end
local top = (slot - 1) * 4 + 1
return PaletteFX.zone(pal, 2, top, 18, top + 1)
end
-- ------- the screen
--
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
-- level or the battle rows change -- which would replace a submenu's rows
-- with the whole top-level OPTIONS list under the player's cursor. A state of
-- our own cannot be caught by it.
--
-- It still renders through OptionRows, so it is the same four boxes, the same
-- cursor and the same bottom line as every other menu in the game.
function SettingsMenu.new(game, catId)
local self = setmetatable({
game = game,
cat = catId or SettingsMenu.ROOT,
index = 1,
scroll = 0,
}, SettingsMenu)
self.rows = SettingsMenu.rows(self.cat, game)
self.sig = SettingsMenu.signature(self.rows)
return self
end
function SettingsMenu.signature(rows)
local ids = {}
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
return table.concat(ids, "\1")
end
-- The bottom line is the only place on this screen to say anything that is
-- not a row: OptionRows' four boxes fill everything above it and there is no
-- header slot. It spends that line on the two buttons that are not obvious.
--
-- It used to carry the category's NAME instead, for orientation. The hint
-- won: a binding nobody knows about is worth nothing, and where the player is
-- was just answered by the row they pressed A on. Sixteen characters of the
-- eighteen the line has, which is also why the name could not stay -- "BACK:
-- PERFORMANCE" is seventeen on its own.
SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
function SettingsMenu:backLabel()
return SettingsMenu.BACK_LABEL
end
-- A category's contents can change while the player is looking at them: 3D-BTL
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
-- actually differs, so the common case -- every other rung of every other row
-- -- costs one string compare.
function SettingsMenu:refresh()
local rows = SettingsMenu.rows(self.cat, self.game)
local sig = SettingsMenu.signature(rows)
if sig == self.sig then return end
-- Follow the row the cursor was ON rather than the slot it was in: a row
-- can appear ABOVE the one just used, which would otherwise slide the
-- cursor onto its neighbour. The bottom line follows itself.
local wasBack = self.index > #self.rows
local wasOn = self.rows[self.index] and self.rows[self.index].id
self.rows, self.sig = rows, sig
self.index, self.scroll = 1, 0
if wasBack then
self.index = #rows + 1
else
for i, row in ipairs(rows) do
if wasOn and row.id == wasOn then self.index = i break end
end
end
end
local function pop(self)
local stack = self.game and self.game.stack
if self.game and self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if stack and stack:top() == self then stack:pop() end
end
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
-- alone, and the bottom line is a synthetic index past the end of the list
-- rather than a row, so nothing a category contains can orphan the way out.
function SettingsMenu:update()
local input = self.game and self.game.input
if not input then return end
local rows = self.rows
local back = #rows + 1
local changed = false
if input:wasPressed("up") then
self.index = self.index - 1
if self.index < 1 then self.index = back end
elseif input:wasPressed("down") then
self.index = self.index + 1
if self.index > back then self.index = 1 end
elseif input:wasPressed("left") or input:wasPressed("right")
or input:wasPressed("a") then
local dir = input:wasPressed("left") and -1 or 1
local row = rows[self.index]
if row and row.activate then
if input:wasPressed("a") then row.activate(self.game) end
elseif row and row.step then
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then
pop(self)
return
end
elseif input:wasPressed("select") then
-- SELECT explains the row the cursor is on. Every row on these menus has
-- something to say -- the settings have carried a paragraph each since
-- they were written, and nothing has ever drawn it (see SettingsHelp) --
-- but a row that does not is simply left alone rather than opening an
-- empty box.
local row = rows[self.index]
local help = row and SettingsMenu.helpFor(row.id)
if help and self.game.stack then
self.game.stack:push(
V.require("SettingsHelp").new(self.game, row.label, help))
end
return
elseif input:wasPressed("b") or input:wasPressed("start") then
-- B and START both, like every other menu -- and one level only: this
-- pops US, leaving the OPTIONS menu underneath exactly as the player
-- left it, with its own onCancel still to fire when they leave THAT.
pop(self)
return
end
if changed then
-- before the rebuild, not after: pinning can itself change which rows are
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
-- it), and refresh has to see the settled answer
if onChanged then pcall(onChanged, self.game) end
if self.game.writeOptions then
pcall(self.game.writeOptions, self.game)
end
end
self:refresh()
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
#self.rows + 1)
end
function SettingsMenu:draw()
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
self:backLabel(), #self.rows + 1)
end
-- REQUIRED, even though nothing here is red.
--
-- Game:draw walks the stack from the top and stops at the first state that
-- HAS this method, not the first that answers something. Without one of our
-- own the walk would fall through to the OPTIONS menu underneath -- whose
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
-- zone is addressed by SLOT, so it would land on whatever this menu happens
-- to be showing in the same box.
--
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
function SettingsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
return SettingsMenu
+23 -3
View File
@@ -217,10 +217,27 @@ local function getBlank()
return blank or nil
end
-- Whether the player asked for shadows at all (the SHADOWS row, see
-- lib/Shadows). Asked through a pcall because this module is loaded by
-- probes and by the suite with no mod namespace around it, where the answer
-- is simply yes.
--
-- ONE gate for both halves of the module -- can the pass run, and is there
-- a map to read -- because they must never disagree: available() alone
-- would leave the LAST map standing (`ready` is still true), and every
-- surface would go on wearing shadows frozen in the pose the row was
-- switched off in.
function ShadowMap.wanted()
local ok, on = pcall(function() return V.require("Shadows").enabled() end)
return (not ok) or on
end
-- Whether the sun pass can run at all. False headless, without shaders, or
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
-- shadows, which need nothing but a quad.
-- shadows, which need nothing but a quad -- and false with the row off,
-- where nothing stands in (see lib/Shadows).
function ShadowMap.available()
if not ShadowMap.wanted() then return false end
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
return false
end
@@ -241,9 +258,12 @@ function ShadowMap.texture()
return getBlank()
end
-- True while the map holds a frame the main pass can read.
-- True while the map holds a frame the main pass can read. The row's OFF
-- lands here as well as on available(): a map drawn a frame ago is still in
-- the canvas, and every reader (the scene shader's sunDark, the water's,
-- the forest's beams) hangs off this one answer.
function ShadowMap.active()
return ready and canvas ~= nil and canvas ~= false
return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted()
end
-- The direction the light TRAVELS, normalized. The shear is the shadow a
+60
View File
@@ -0,0 +1,60 @@
-- Voxel world mode: whether the sun casts at all.
--
-- lib/ShadowMap renders the whole scene a second time from the light every
-- frame the view or a pose changes, at up to 2048 squared, and every
-- surface in the main pass then takes four taps at it. That is the single
-- most expensive thing this mode does after the geometry itself -- and on a
-- phone, or an old laptop, it is the difference between the diorama running
-- and the diorama stuttering. So it gets a row.
--
-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
-- a stand-in for a machine that wanted shadows and could not have them.
-- A player who has just switched them off wants no shadow under anybody,
-- which is what this row gives -- see ShadowMap.wanted, the one gate both
-- halves hang off.
--
-- This file owns the toggle rather than the drawing: the value, where it
-- persists, and the row the player finds it on -- exactly as VoxelGrid does
-- for the wireframe.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Shadows = {}
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
-- OPTIONS and the mod manager's own settings page for this mod
Shadows.KEY = "shadows"
Shadows.LABEL = "SHADOWS"
-- ON is values[1] and so the default: cast shadows are what the mode is
-- for as much as the geometry is -- a world where a building throws
-- nothing reads as flat however many voxels it is made of. The row is for
-- the machine that cannot carry them, not a look anybody is choosing.
Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
{ true, false }, { "ON", "OFF" })
function Shadows.enabled()
return Shadows.setting:get() and true or false
end
function Shadows.set(enabled, game)
return Shadows.setting:setIndex(enabled and 1 or 2, game)
end
function Shadows.toggle(game)
return Shadows.setting:cycle(game)
end
function Shadows.sync(value)
Shadows.setting:sync(value and true or false)
end
function Shadows.row()
return Shadows.setting:row()
end
return Shadows
+289
View File
@@ -0,0 +1,289 @@
-- Shiny Pokemon: the one fact, and everywhere that asks it.
--
-- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no
-- shininess of its own, but it does have the four DVs Gen 2 later read to
-- decide it, and the engine already ships that reading:
-- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all
-- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
-- This is that mod.
--
-- Deriving rather than storing is the whole design, and it buys a great
-- deal:
--
-- * It persists for free. DVs are already in every save, every PC box,
-- every trade. No new save field, no migration, and a save made before
-- this mod was installed already HAS shiny Pokemon in it -- they were
-- always there, nothing was ever drawn differently.
-- * It survives evolution. Evolution.apply recalculates stats from the
-- same dvs table and never touches it (src/pokemon/Evolution.lua:99),
-- so a shiny Bulbasaur is a shiny Venusaur without being told.
-- * It cannot desync. A flag stored beside the DVs is a second copy of
-- the truth, and two copies drift -- most cruelly across a trade or a
-- box deposit, where the mon travels and the sidecar does not.
-- * PKHeX and the Gen 2 games agree with us, because it is their rule.
--
-- The odds, though, are ours to set, and that is the one thing DVs alone
-- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 =
-- exactly 1/8192, the classic rate, and there is no dial on it. So the roll
-- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs
-- (forceShiny/forceCommon below). The mon does not carry a flag saying it
-- is shiny; it is made genuinely shiny by the game's own formula, and every
-- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the
-- same answer without knowing we were involved.
--
-- mon.shiny is maintained too, but it is a CACHE and never the source: see
-- Shiny.mark.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
-- allowlisted for mods by name -- src/mods/Loader.lua:71 lists
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
local Stats = require("src.pokemon.Stats")
local ModSetting = V.require("ModSetting")
local Shiny = {}
-- ------- the odds
--
-- One in ODDS_DENOM. The default is 8192 because that is what random DVs
-- already produce, so a player who never changes it gets the canonical rate
-- and the canonical feel -- this mod's default is not a buff.
--
-- The roll is made EXACT rather than additive. A naive implementation rolls
-- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a
-- miss, so the true rate is N and 8192 in parallel -- indistinguishable at
-- the default and quietly wrong at every other setting (at 1/100 you would
-- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no
-- matter what you set). forceCommon on a miss closes that: the rate is what
-- the number says.
Shiny.ODDS_DENOM = 8192
-- ------- the row the player cycles
--
-- A ladder that HALVES, so every step is exactly "twice as often as the one
-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
-- rate is what the number says, not an approximation of it, because the
-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
-- 1:2 really is every other encounter.
--
-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
-- and the fallback for an unreadable or unrecognised stored value, so the
-- canonical rate is what a player who never opens the menu gets and what a
-- corrupted options.lua comes back to.
--
-- No rung RARER than 8192. The mod's promise is that its default is not a
-- change to the game; making the game harder than it ships is a different
-- promise and nobody asked for it.
local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
local ODDS_LABELS = {}
for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
-- ------- the setting is PULLED, not pushed
--
-- decide() asks this every roll rather than the menu telling us when it
-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
-- settings page -- and only the first has a change hook to hang on; the
-- manager writes through mod.options and calls ModSetting:sync, which
-- notifies nothing. Pulling is the only way both are seen, and the cost is
-- a table read on an event that happens once per encounter.
--
-- ODDS_DENOM stays the live value and is written through on every ask, so
-- anything already reading that field keeps reading the truth.
local pinned = false
function Shiny.odds()
if not pinned then
local ok, value = pcall(Shiny.setting.get, Shiny.setting)
local n = ok and tonumber(value)
if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
end
return Shiny.ODDS_DENOM
end
-- Set the denominator BY HAND, which also PINS it: a driver or a test that
-- has asked for 1:1 means it, and must not have the next roll quietly put
-- back to whatever the player left on the menu. Nothing in the game calls
-- this -- the row is how a player changes the rate.
--
-- Guards the degenerate values because a 0 or a negative here would
-- divide-by-zero or make every encounter shiny by accident rather than by
-- choice; 1 (always shiny) stays reachable because it is genuinely useful
-- for walking the whole model set.
function Shiny.setOdds(denom)
denom = tonumber(denom)
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
Shiny.ODDS_DENOM = math.floor(denom)
pinned = true
return Shiny.ODDS_DENOM
end
-- Hand the row back control, for a test that pinned the odds and wants the
-- setting to mean something again afterwards.
function Shiny.unpinOdds()
pinned = false
return Shiny.odds()
end
-- ------- reading it
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
-- list as well as the engine's set because forceShiny has to CHOOSE one and
-- wants the nearest, not just any.
local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 }
-- The HP DV is not free: Gen 1 derives it from the low bit of each of the
-- other four (src/pokemon/Stats.lua:19). Any write to the four must
-- recompute it, or the mon ends up with an HP stat the real game could
-- never produce -- which is exactly what a save inspector flags as illegal.
local function syncHpDv(dvs)
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
(dvs.speed % 2) * 2 + (dvs.special % 2)
return dvs
end
-- The single question. Everything visual in this mod routes here.
function Shiny.isShiny(mon)
if type(mon) ~= "table" then return false end
return Stats.isShiny(mon.dvs) == true
end
-- ------- writing it
-- Make these DVs satisfy the pattern, moving them as little as it allows.
--
-- Defense, Speed and Special have exactly one legal value each, so they are
-- simply pinned. Attack has eight, and the nearest one to whatever was
-- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0
-- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter
-- should not also be a stat reroll any larger than the pattern demands.
local function forceShiny(dvs)
local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil
for _, v in ipairs(SHINY_ATK) do
local d = math.abs(v - want)
if not bestd or d < bestd then bestd, best = d, v end
end
dvs.attack = best
dvs.defense, dvs.speed, dvs.special = 10, 10, 10
return syncHpDv(dvs)
end
-- Make these DVs NOT satisfy the pattern, moving them as little as
-- possible: one step on Special is enough to break it, and Special is the
-- choice because in Gen 1 it is a single stat rather than the two Gen 2
-- split it into, so the disturbance stays inside one number.
--
-- Only ever reached by a mon that rolled non-shiny and happened to be shiny
-- by luck, which is 1/8192 of the time -- so this touches almost nothing,
-- and what it does touch it moves by one point.
local function forceCommon(dvs)
if (dvs.special or 0) == 10 then
dvs.special = 9
elseif (dvs.defense or 0) == 10 then
dvs.defense = 9
end
return syncHpDv(dvs)
end
-- mon.shiny: the cache.
--
-- The requirement is a flag ON the Pokemon, and this is it -- but it is
-- written from the DVs every time we touch a mon, never read as the truth.
-- Keeping it one-directional is what stops it becoming the second copy the
-- header warns about: if it ever disagrees with the DVs, the DVs win and
-- this is overwritten. It exists so other code -- and a save inspector, and
-- a companion mod -- can ask the cheap question without importing Stats.
function Shiny.mark(mon)
if type(mon) ~= "table" then return false end
local is = Shiny.isShiny(mon)
mon.shiny = is or nil -- nil rather than false: absent keeps saves clean
return is
end
-- Recalculate the stats a DV write invalidated.
--
-- Split out because both decide() and set() move DVs, and a mon left
-- carrying stats computed from its old DVs is wrong in the only way the
-- player can actually see: its HP bar.
local function restat(mon)
if not (mon.level and mon.species) then return end
local ok, data = pcall(require, "src.core.Data")
local def = ok and data and data.pokemon and data.pokemon[mon.species]
if not def then return end
local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0)
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
-- A wild mon appears at full health, and a mon that WAS full stays full:
-- recomputing max HP without following it here would put a freshly
-- encountered mon on the field at less than full from its first frame.
-- A wounded mon keeps its damage, clamped to the new maximum.
if mon.hp then
mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp)
end
end
-- ------- our own randomness
--
-- A PRIVATE stream, not love.math.random, and that is deliberate.
--
-- The game's RNG is a shared sequence: damage rolls, crits, encounter
-- slots and DV generation all draw from it in a fixed order. Taking a draw
-- out of it for a shiny check would shift every later draw, so installing
-- this mod would quietly change the outcome of fights that have nothing to
-- do with shininess -- and the manifest promises `affects_link = false`,
-- which a shifted stream would make untrue the moment two machines
-- disagreed about whose turn consumed what.
--
-- Seeded off the clock rather than the save, because shininess is a fact
-- about the encounter and not about the file: re-loading a save to re-roll
-- a Pokemon is the hunt, and a stream keyed to the save would hand back the
-- same answer every time.
local stream = nil
local function roll(n)
if not stream then
if love and love.math and love.math.newRandomGenerator then
stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6)
else
-- headless (tests): math.random is nobody's shared sequence there
stream = { random = function(_, a, b) return math.random(a, b) end }
end
end
return stream:random(1, n)
end
-- Decide a freshly-built mon, in place.
--
-- rng may be passed to pin the verdict -- a test hands us a stub. Left nil,
-- the private stream above is used.
function Shiny.decide(mon, rng)
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
-- stub and the call below reads identically either way
rng = rng or function(_lo, hi) return roll(hi) end
local hit = rng(1, Shiny.odds()) == 1
if hit then
forceShiny(mon.dvs)
restat(mon)
elseif Stats.isShiny(mon.dvs) then
forceCommon(mon.dvs)
restat(mon)
end
return Shiny.mark(mon)
end
-- Force a specific verdict: for tests, and for a scripted gift mon that
-- wants to be shiny on purpose.
function Shiny.set(mon, on)
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end
restat(mon)
return Shiny.mark(mon)
end
return Shiny
+97
View File
@@ -0,0 +1,97 @@
-- Where shininess enters the game, and where it is shown.
--
-- ------- one seam decides it
--
-- Every Pokemon the player can ever own is built by Pokemon.new
-- (src/pokemon/Pokemon.lua:60). There are five callers and they are the
-- whole surface:
--
-- BattleState.lua:569 the wild encounter
-- BattleState.lua:659 a trainer's party
-- BattleState.lua:785 the level-5 stand-in the Oak battle builds
-- Commands.lua:656 a gift or a starter
-- Commands.lua:969 an in-game trade
--
-- So the roll goes THERE rather than on the encounter hooks. Two reasons,
-- and the second is decisive:
--
-- * encounter.roll and encounter.species fire before the mon exists --
-- they carry {species, level} and nothing to write a verdict onto.
-- * makeBattler bakes mon.sprite INSIDE newWild
-- (src/battle/BattleState.lua:455-461), before battle.started is
-- emitted. A verdict applied at battle.started is already too late for
-- the sprite the fight will draw.
--
-- Wrapping the constructor puts the decision before every one of those, and
-- picks up gift mons, starters and trades for free rather than needing a
-- seam each.
--
-- TRAINER MONS COME OUT NON-SHINY BY THEMSELVES, and correctly so. The
-- engine overwrites every trainer slot's DVs with a fixed TRAINER_DVS
-- (src/battle/BattleState.lua:350, :661) right after construction, and that
-- constant fails the shiny pattern. So the roll is made and then discarded
-- for them -- which matches the real games, where a trainer's Pokemon is
-- never shiny.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Shiny = V.require("Shiny")
local ShinyBattle = {}
-- ------- install
--
-- Idempotent by sentinel, the pattern every wrap in this mod uses
-- (OverworldBattle.install, Stadium.install): a hot reload must not stack a
-- second copy of the wrapper on top of the first.
function ShinyBattle.install()
local Pokemon = require("src.pokemon.Pokemon")
if not Pokemon.dramaticShapeShiny then
local inner = Pokemon.new
function Pokemon.new(data, species, level, rng)
local mon = inner(data, species, level, rng)
-- pcall: a mon that fails to be decided is an ordinary mon, which is
-- a blemish. A mon that fails to be BUILT is a broken game.
pcall(Shiny.decide, mon)
return mon
end
Pokemon.dramaticShapeShiny = true
end
-- Party mons that predate the mod, and any mon built by a path we have
-- not wrapped, still answer isShiny correctly -- their DVs were always
-- there. This only refreshes the mon.shiny cache so a save opened for the
-- first time under this mod has the field populated rather than absent
-- until the mon next changes.
ShinyBattle.markParty()
end
-- Refresh the cached flag across the player's party.
function ShinyBattle.markParty()
local ok, Game = pcall(require, "src.core.Game")
if not ok then return end
local party = Game and Game.save and Game.save.party
if type(party) ~= "table" then return end
for _, mon in ipairs(party) do
pcall(Shiny.mark, mon)
end
end
-- ------- asking about a battler
--
-- The battler wrapper carries the save-shaped mon on `.mon`
-- (src/battle/BattleState.lua:432-463), so the question is always about
-- that table and never about the wrapper.
function ShinyBattle.battlerIsShiny(battler)
return battler ~= nil and Shiny.isShiny(battler.mon)
end
-- Which side of a battle, by the engine's own side names.
function ShinyBattle.sideIsShiny(battle, side)
if not battle then return false end
return ShinyBattle.battlerIsShiny(side == "player" and battle.player
or battle.enemy)
end
return ShinyBattle
+324
View File
@@ -0,0 +1,324 @@
-- The shiny sparkle: the flash a Pokemon makes when it first appears.
--
-- The games announce a shiny with a burst of stars over the sprite the
-- instant it lands, before the first text box. This is that moment, in the
-- diorama: a ring of additive stars that springs outward from the mon's
-- chest, rises, and fades over about three quarters of a second.
--
-- ------- where the moment IS
--
-- Harder than it sounds, because the two battle paths arrive differently:
--
-- the model rung a Pokemon grows out of its ball -- Stadium.update
-- already finds that frame (the POOF_ANIM edge) and
-- calls StadiumMon:beginGrow.
-- the pic rung a WILD foe is simply THERE on the first frame, with
-- no poof and no grow at all. There is no animation to
-- hang off.
--
-- So the arming edge is neither of those: it is the frame a side's OCCUPANT
-- changes (Stadium's `session.at[side] ~= battler` test, the same identity
-- the mode already uses because a trainer leading with two Rattata changes
-- occupant without changing species). That edge fires for a send-out, a
-- switch and a wild foe alike, which is exactly the set of moments a shiny
-- should announce itself.
--
-- ------- drawn additively, and why it survives the flash
--
-- Stars are light, so they add rather than cover: `Voxel3D.blend("add")`,
-- the same treatment the Poke Ball's glow gets. They are drawn inside the
-- battle's flash window alongside the cards and models, so a sparkle during
-- a hit flash is lit by it like everything else rather than floating over
-- it as a separate layer.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Voxel3D = V.require("Voxel3D")
local Mat4 = V.require("Mat4")
local BattleBillboard = V.require("BattleBillboard")
local ShinyFx = {}
local max, min = math.max, math.min
-- ------- shape and timing
-- ------- sized to the Pokemon, not to a constant
--
-- A Pokemon on the map is between 5 and 18 world pixels tall
-- (StadiumMon.MIN_HEIGHT/MAX_HEIGHT, REF_HEIGHT 14) and roughly its own
-- radius wide. Every earlier attempt here used flat numbers and every one of
-- them was wrong for most of the dex: first a ring 7 units across, which sat
-- INSIDE anything bigger than a Rattata and was depth-rejected; then, over-
-- correcting, a ring 24 across starting 20 units up -- taller than the
-- tallest Pokemon there is, so it hung in the sky above a Ponyta with
-- nothing under it.
--
-- One ring cannot fit a Diglett and a Gyarados. The burst is therefore a
-- FRACTION of the mon it belongs to: Stadium hands us each side's
-- worldHeight and worldRadius (StadiumMon has them, and worldRadius exists
-- precisely so "a caller can size something to its footprint"), and every
-- distance below is measured off those.
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
ShinyFx.STARS = 10 -- around the ring
ShinyFx.CHEST_FRAC = 0.50 -- up the body: the ring is centred on the
-- Pokemon, not perched above or below it
ShinyFx.RISE_FRAC = 0.16 -- of its height, drifted up over the burst
ShinyFx.SIZE_FRAC = 0.20 -- a star, as a fraction of the mon's height
-- THE RING IS AN ELLIPSE AROUND THE SILHOUETTE, with its two axes measured
-- separately. A single radius cannot do this: flattened enough to look like
-- a ring seen from the battle's low seat, its vertical reach ends up a third
-- of the body's height, so the top and bottom stars sit ON the Pokemon. The
-- horizontal axis clears its width, the vertical axis clears its height.
ShinyFx.RING_X_FRAC = 1.50 -- of the mon's RADIUS -- just outside its width
ShinyFx.RING_X_MIN = 0.34 -- ...but never narrower than this of its height,
-- for the thin ones (Onix, Ekans) whose radius
-- alone would put the ring inside them
ShinyFx.RING_Y_FRAC = 0.62 -- of its HEIGHT -- so the ring reaches its
-- shoulders and its feet, not just its middle
-- The burst OPENS from here rather than from nothing. Springing out of a
-- point means every star spends the first frames stacked at the centre --
-- which is the middle of the Pokemon, and reads exactly like the sparkles
-- being stuck inside it. Starting already clear of the body and expanding
-- the rest of the way keeps them outside for the whole life of the effect.
ShinyFx.RING_START = 0.72
-- What a side with no model gets: the flat-pic rung, where a pic stands
-- FULL_W (16) units wide in a card. Close enough to a median Pokemon that
-- the same fractions land sensibly.
ShinyFx.DEFAULT_HEIGHT = 14
ShinyFx.DEFAULT_RADIUS = 6
-- Additive drawing keeps the depth TEST (Voxel3D.blend sets lequal with
-- writes off), so a star level with the model is rejected by it however
-- bright it is. The extra pull puts the ring in front of the Pokemon it
-- belongs to, the same trick the move-animation card uses.
ShinyFx.PULL_BONUS = 6
-- one per side, nil when nothing is playing
local live = { player = nil, enemy = nil }
-- How big the Pokemon on each side actually is, pushed in by Stadium.update
-- every frame it has a model. Kept here rather than reached for, because
-- ShinyFx is drawn from BattleScene and asking Stadium from inside it would
-- close a require loop between the three.
local size = { player = nil, enemy = nil }
-- world pixels, from StadiumMon:worldHeight/worldRadius. Pass nil height to
-- say "no model on this side" -- the flat-pic rung, which falls back to the
-- defaults above.
function ShinyFx.setMetrics(side, height, radius)
if side ~= "player" and side ~= "enemy" then return end
if not (height and height > 0) then size[side] = nil return end
size[side] = { h = height, r = radius or 0 }
end
local star = nil -- the generated star image, built once
-- ------- the star
--
-- Generated rather than shipped: it is a four-pointed twinkle, which is a
-- cheap closed form (a radial falloff times a cross-shaped spike term) and
-- costs nothing next to an asset that would have to be authored, packed,
-- loaded and kept in step with the rest of the mod's art.
local function starImage()
if star ~= nil then return star or nil end
if not (love and love.image and love.graphics) then
star = false
return nil
end
local ok, img = pcall(function()
local N = 32
local data = love.image.newImageData(N, N)
local c = (N - 1) / 2
for y = 0, N - 1 do
for x = 0, N - 1 do
local dx, dy = (x - c) / c, (y - c) / c
local r = math.sqrt(dx * dx + dy * dy)
-- the body: a soft core that is gone by the edge of the square
local core = math.max(0, 1 - r)
core = core * core * core
-- the spikes: bright along the two axes, narrow, and reaching
-- further out than the core does
local ax, ay = math.abs(dx), math.abs(dy)
local spike = math.max(0, 1 - ax * 6) * math.max(0, 1 - ay)
+ math.max(0, 1 - ay * 6) * math.max(0, 1 - ax)
local a = math.min(1, core + spike * 0.55)
-- white with the faintest warm cast, so a sparkle over a cool
-- model still reads as light rather than as a blue smear
data:setPixel(x, y, 1, 1, 0.97, a)
end
end
return love.graphics.newImage(data)
end)
star = (ok and img) or false
return star or nil
end
-- ------- arming
-- Start (or restart) the burst on one side. Restarting rather than ignoring
-- a second call is deliberate: a shiny that faints and is sent back out
-- should sparkle again.
-- ARMED, BUT NOT YET RUNNING. The clock does not start here, and that is the
-- whole point: the edge this is armed on -- a side's occupant changing --
-- happens while the screen is still mid-WIPE, a second or more before the
-- battle draws a single frame. A burst that started its three-quarter-second
-- life at that moment was always over before anybody could see it, which is
-- exactly what "the sparkle isn't appearing" looked like: armed, drawn,
-- counted, and finished behind the transition.
--
-- So `pending` holds it at frame zero until the scene actually draws this
-- side (see draw), and the life begins from there.
function ShinyFx.arm(side)
if side ~= "player" and side ~= "enemy" then return end
live[side] = { t = 0, pending = true }
if ShinyFx.debug then ShinyFx.debug.armed = (ShinyFx.debug.armed or 0) + 1 end
end
-- The fight is on screen now: let any burst waiting on this side begin.
--
-- Split from arm because the two moments are genuinely different and were
-- conflated twice. Arming happens when the OCCUPANT changes, which is during
-- the transition; the burst may only start once the transition is OVER and
-- there is somebody watching. Between them it sits at zero.
function ShinyFx.release(side)
local s = live[side]
if s and s.pending then
s.pending = nil
if ShinyFx.debug then
ShinyFx.debug.released = (ShinyFx.debug.released or 0) + 1
end
end
end
function ShinyFx.clear(side)
if ShinyFx.debug and side and live[side] then
ShinyFx.debug.cleared = (ShinyFx.debug.cleared or 0) + 1
end
if side then live[side] = nil else live.player, live.enemy = nil, nil end
end
function ShinyFx.active(side)
if side then return live[side] ~= nil end
return live.player ~= nil or live.enemy ~= nil
end
function ShinyFx.update(dt)
dt = dt or 0
for _, side in ipairs({ "player", "enemy" }) do
local s = live[side]
-- a pending burst does not age: it is waiting for the scene to draw it
-- for the first time, which is when its life actually begins (see arm)
if s and not s.pending then
s.t = s.t + dt
if s.t >= ShinyFx.LIFE then live[side] = nil end
end
end
end
-- ------- drawing
-- Eased so the ring leaves fast and settles, which is what a spark does;
-- linear looks like a diagram of a spark.
local function easeOut(u) return 1 - (1 - u) * (1 - u) end
-- Draw whatever is playing. `arena` and `groundY` come from the scene, the
-- same two the mon cards are placed from, so a sparkle lands where its
-- Pokemon is standing rather than where the layout thinks it should be.
-- Why a burst did not draw, for a driver to read back. Rendering faults are
-- invisible to the test suite and this one has four separate ways to be a
-- no-op, all of them silent.
ShinyFx.debug = { calls = 0, noArena = 0, noImage = 0, noMesh = 0,
noLive = 0, quads = 0, armed = 0, cleared = 0 }
function ShinyFx.draw(arena, groundY, pull)
local dbg = ShinyFx.debug
dbg.calls = dbg.calls + 1
if not arena then dbg.noArena = dbg.noArena + 1 return end
local img = starImage()
if not img then dbg.noImage = dbg.noImage + 1 return end
local mesh = BattleBillboard.mesh()
if not mesh then dbg.noMesh = dbg.noMesh + 1 return end
if not (live.player or live.enemy) then
dbg.noLive = dbg.noLive + 1
return
end
local drew = false
for _, side in ipairs({ "player", "enemy" }) do
local s = live[side]
local cell = (side == "player") and arena.player or arena.enemy
-- A pending burst is not drawn at all. It is waiting for the fight to be
-- ON SCREEN, which is not the same as the scene being drawn: the battle
-- renders underneath the transition wipe for a second or so first, and a
-- burst started there spends its whole life behind it. Stadium.release
-- is what says the wipe is done.
if s and s.pending then s = nil end
if s and cell then
local u = math.min(1, s.t / ShinyFx.LIFE)
local e = easeOut(u)
-- bright immediately, then out: the announcement is the first frame
local alpha = 1 - u * u
local x, z = cell[1], cell[2]
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
-- every distance measured off THIS Pokemon (see the header)
local m = size[side]
local mh = (m and m.h) or ShinyFx.DEFAULT_HEIGHT
local mr = (m and m.r and m.r > 0 and m.r) or ShinyFx.DEFAULT_RADIUS
local ringX = max(mr * ShinyFx.RING_X_FRAC, mh * ShinyFx.RING_X_MIN)
local ringY = mh * ShinyFx.RING_Y_FRAC
local starK = mh * ShinyFx.SIZE_FRAC
-- open from clear of the body, not from a point (see RING_START)
local grow = ShinyFx.RING_START + (1 - ShinyFx.RING_START) * e
local baseY = groundY + mh * ShinyFx.CHEST_FRAC
+ mh * ShinyFx.RISE_FRAC * e
if not drew then
Voxel3D.blend("add")
Voxel3D.seams(false)
Voxel3D.glass(false)
drew = true
end
for i = 1, ShinyFx.STARS do
-- the ring is offset half a step per side so the two sides do not
-- twinkle in lockstep when both are shiny
local a = (i / ShinyFx.STARS) * math.pi * 2
+ (side == "player" and 0.31 or 0)
-- stars shrink as they fade, and alternate size so the ring reads
-- as scattered rather than as a cog
local k = starK * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
local ox = math.cos(a) * ringX * grow
local oy = math.sin(a) * ringY * grow
local m = Mat4.mul(
Mat4.mul(Mat4.translate(x, baseY, z), Mat4.rotateY(yaw)),
Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(k, k, 1)))
love.graphics.setColor(1, 1, 1, alpha)
Voxel3D.draw(mesh, img, m, (pull or 0) + ShinyFx.PULL_BONUS)
dbg.quads = dbg.quads + 1
end
end
end
if drew then
love.graphics.setColor(1, 1, 1, 1)
Voxel3D.glass(true)
Voxel3D.seams(true)
Voxel3D.blend("alpha")
end
end
-- Drop the generated image (hot reload, or a graphics context that went
-- away) -- the same contract StadiumPack.invalidate honours.
function ShinyFx.invalidate()
if star and star.release then pcall(star.release, star) end
star = nil
ShinyFx.clear()
end
return ShinyFx
+416
View File
@@ -0,0 +1,416 @@
-- The shiny recolour: Stadium's own HSL slide, run over decoded texels.
--
-- THE COLOUR MODEL IS STADIUM'S, not an invention. The Stadium games do not
-- ship a second set of textures for a shiny Pokemon; they convert the
-- colours the model already has to HSL and slide them -- a hue rotation in
-- degrees, plus saturation and lightness on a quantized integer scale of
-- -8..+8 where 0 is no change. One step is 12.5%, so +-8 is +-100%: exactly
-- the range of GIMP's Hue-Saturation sliders, which is where the 12.5%
-- figure was measured. s = -8 is full greyscale, l = +8 is white.
--
-- That equivalence is why the maths below is GIMP's Hue-Saturation and not
-- a plain additive offset:
--
-- saturation s' = s * (1 + k) multiplicative
-- lightness l' = l * (1 + k) k < 0 scale toward black
-- l' = l + k * (1 - l) k > 0 blend toward white
--
-- The multiplicative saturation is the reason this is safe to run over a
-- whole texture rather than a masked region: a pixel with no saturation --
-- an eye white, a tooth, a grey shadow -- is immune to BOTH the hue
-- rotation and the saturation step, for free and by construction. Only the
-- lightness step touches achromatic pixels, which is why the species
-- carrying big l values (Golbat and Slowpoke at -6, Moltres at +5) are the
-- ones worth looking at with human eyes.
--
-- FIVE SPECIES CANNOT BE SLID. Clefairy, Clefable, Jigglypuff, Wigglytuff
-- and Gyarados get a real alternate texture in Stadium, because their shiny
-- moves one region a long way and leaves another alone -- Jigglypuff's body
-- stays pink while its irises go green -- and a single rotation moves
-- everything or nothing.
--
-- Those five carry `lut` instead: an explicit before/after colour mapping,
-- sampled from the verified texture pairs, listing only the colours that
-- actually move. A first attempt drove them from a handful of per-region
-- anchors and picked the nearest one per pixel, which is wrong in a way
-- worth recording: with regions as far apart as Clefairy's pink body and
-- its green ear tips, a dark red shadow pixel is "nearest" to the green and
-- gets rotated 150 degrees the wrong way. The fixture caught it at a
-- 124/255 channel error. An exact table is a few tens of kilobytes and has
-- no such failure mode, so these five are data rather than algorithm.
--
-- WHY THIS RUNS AT EXTRACTION. The textures are already decoded to RGBA in
-- memory at that moment (StadiumFragment.decodeTexture), and -- the part
-- that matters -- generated effect frames are still distinguishable there.
-- StadiumFx marks them `generated = true`, and the packer drops that field,
-- so at runtime an additive flame can only be inferred back from the prim
-- table. Recolouring a flame is wrong: a shiny Charizard has a shiny hide
-- and an ordinary fire. Doing the work while the marker still exists means
-- the discrimination is exact rather than reconstructed.
--
-- THE MEMO IS WHAT MAKES IT AFFORDABLE. These are N64 textures: a few
-- hundred distinct colours across tens of thousands of texels. Converting
-- per DISTINCT COLOUR instead of per pixel turns the inner loop into a
-- table lookup, which is the difference between a pass that is felt during
-- the install and one that is not.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ShinyPalette = {}
local floor, min, max, abs = math.floor, math.min, math.max, math.abs
local byte, char, concat = string.byte, string.char, table.concat
-- ------- HSL
local function rgbToHsl(r, g, b)
r, g, b = r / 255, g / 255, b / 255
local mx, mn = max(r, g, b), min(r, g, b)
local l = (mx + mn) / 2
if mx == mn then return 0, 0, l end -- achromatic: hue is undefined
local d = mx - mn
local s = l > 0.5 and d / (2 - mx - mn) or d / (mx + mn)
local h
if mx == r then
h = (g - b) / d + (g < b and 6 or 0)
elseif mx == g then
h = (b - r) / d + 2
else
h = (r - g) / d + 4
end
return h * 60, s, l
end
local function hue2rgb(p, q, t)
if t < 0 then t = t + 1 end
if t > 1 then t = t - 1 end
if t < 1 / 6 then return p + (q - p) * 6 * t end
if t < 1 / 2 then return q end
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
return p
end
local function hslToRgb(h, s, l)
if s <= 0 then
local v = floor(l * 255 + 0.5)
return v, v, v
end
h = (h % 360) / 360
local q = l < 0.5 and l * (1 + s) or l + s - l * s
local p = 2 * l - q
return floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5),
floor(hue2rgb(p, q, h) * 255 + 0.5),
floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5)
end
-- GIMP's two curves, shared by the slide and the anchor paths so both
-- reach the same colour from the same k.
local function shiftSat(s, k)
if k == 0 then return s end
return max(0, min(1, s * (1 + k)))
end
local function shiftLight(l, k)
if k == 0 then return l end
if k < 0 then return max(0, l * (1 + k)) end
return min(1, l + k * (1 - l))
end
-- ------- the two kinds of transform
-- A whole-model slide: the 146 species Stadium recolours this way.
local function slideFn(slide)
local dh = slide.h or 0
local ks = (slide.s or 0) * 0.125
local kl = (slide.l or 0) * 0.125
return function(r, g, b)
local h, s, l = rgbToHsl(r, g, b)
-- An achromatic pixel has no hue to rotate and no saturation to scale;
-- only a lightness step can reach it. Returning early is not just
-- speed, it is exactness: round-tripping grey through HSL and back can
-- move it by a unit, and a tooth that drifts is a visible defect.
if s <= 0 then
if kl == 0 then return r, g, b end
local v = floor(shiftLight(l, kl) * 255 + 0.5)
return v, v, v
end
return hslToRgb(h + dh, shiftSat(s, ks), shiftLight(l, kl))
end
end
-- An exact colour mapping: the five species Stadium gives a real second
-- texture. A colour absent from the table is one the alternate texture left
-- alone, so passing it straight through is the correct answer, not a
-- fallback -- that is how Wigglytuff keeps its white belly and its black
-- inner ears while its body moves to lilac.
local function lutFn(lut)
return function(r, g, b)
local hit = lut[r * 65536 + g * 256 + b]
if not hit then return r, g, b end
return floor(hit / 65536) % 256, floor(hit / 256) % 256, hit % 256
end
end
-- ------- the colour table
--
-- Loaded lazily and cached. Two paths on purpose: through the mod namespace
-- when the mod is running, and straight off disk when it is not. The
-- extraction byte-diff (tests/stadium_extract_test.lua) stubs V with only
-- `require` and `mod.log`, and the recolour has to be exercisable under
-- exactly that harness -- a colour transform that can only run inside a
-- live LOVE process is a colour transform nobody will test.
local colors = nil
local function loadColors()
if colors ~= nil then return colors or nil end
if V and V.data then
local ok, t = pcall(V.data, "shiny_colors")
if ok and type(t) == "table" then colors = t; return colors end
end
-- Off disk, RELATIVE TO THE MOD rather than to the working directory.
-- V.path is the mod's own directory (main.lua sets it; the headless
-- harnesses set it to whatever --mod they were given). Guessing from the
-- cwd instead is what made this silently find nothing when the extraction
-- test was run from the project root rather than from the mod: every
-- species built, none recoloured, and a PASS at the end of it.
local tries = {}
if V and V.path then tries[#tries + 1] = V.path .. "/data/shiny_colors.lua" end
tries[#tries + 1] = "data/shiny_colors.lua"
tries[#tries + 1] = "mods/DramaticShapeVoxelMod/data/shiny_colors.lua"
for _, p in ipairs(tries) do
local chunk = loadfile(p)
if chunk then
local ok, t = pcall(chunk)
if ok and type(t) == "table" then colors = t; return colors end
end
end
colors = false -- cache the miss; do not retry the disk per species
return nil
end
-- Whether the colour table was found at all. The extraction asks so it can
-- say "no colours" once and loudly, rather than reporting 151 successful
-- builds with no shiny variant among them.
function ShinyPalette.haveColors()
return loadColors() ~= nil
end
-- The spec for one dex number, or nil if we have nothing for it.
function ShinyPalette.forDex(dex)
local all = loadColors()
return all and all[dex] or nil
end
-- Build the pixel transform for one species' spec, or nil when there is
-- nothing to do.
function ShinyPalette.transform(spec)
if type(spec) ~= "table" then return nil end
if spec.lut then
if next(spec.lut) == nil then return nil end
return lutFn(spec.lut)
end
local s = spec.slide
if not s then return nil end
if (s.h or 0) == 0 and (s.l or 0) == 0 and (s.s or 0) == 0 then return nil end
return slideFn(s)
end
-- ------- the pass over one texture
--
-- Memoised per distinct colour (see the header). The key packs RGB into one
-- integer because a table with 24-bit integer keys is a flat array probe,
-- where a "r,g,b" string key would allocate on every pixel -- and allocation
-- inside a multi-million-iteration loop is the whole cost.
--
-- Alpha is copied through untouched, never premultiplied and never
-- recomputed: the transform is defined on colour alone, and a shiny
-- Gastly's soft edge must stay exactly as soft as it was.
function ShinyPalette.recolorTexels(rgba, fn)
local n = #rgba
if n == 0 or not fn then return rgba end
local memo = {}
local out, blocks = {}, {}
local bi = 0
for i = 1, n, 4 do
local r, g, b, a = byte(rgba, i, i + 3)
local key = r * 65536 + g * 256 + b
local hit = memo[key]
if not hit then
local nr, ng, nb = fn(r, g, b)
hit = { nr, ng, nb }
memo[key] = hit
end
bi = bi + 1
blocks[bi] = char(hit[1], hit[2], hit[3], a)
-- flushed in blocks so the concat never walks a table with millions of
-- one-texel strings in it
if bi >= 4096 then
out[#out + 1] = concat(blocks)
blocks, bi = {}, 0
end
end
if bi > 0 then out[#out + 1] = concat(blocks, "", 1, bi) end
return concat(out)
end
-- ------- a tint, for the flat sprites
--
-- The 3D models get real recoloured texels. The 2D battle pics cannot: the
-- engine bakes a species palette into a cached image keyed by path and
-- palette name, and that cache has no idea which INDIVIDUAL is being drawn.
-- What is available per-draw is the draw colour, which multiplies.
--
-- So the pic is tinted, and the tint is derived from the species' OWN shiny
-- slide rather than being a generic gold: run a spread of reference colours
-- through the real transform, take the mean ratio out to in, and that is
-- the multiply that best stands in for it. A shiny Golbat leans green, a
-- shiny Charizard goes dusky, and neither is a costume.
--
-- ITS ONE LIMIT, stated plainly: a multiply can only darken. Where a species'
-- shiny is LIGHTER than its normal, the honest ratio is above 1 and gets
-- clamped, so those come out under-shifted -- present, but quieter than the
-- model. The floor keeps the darkest cases readable rather than muddy.
local TINT_FLOOR = 0.45
local tintCache = {}
-- Mid-tone references across the wheel. Deliberately not greys: the slide
-- is multiplicative in saturation, so a grey reference would report no
-- change for every species and hand back a tint of 1,1,1.
local REFS = {
{ 200, 90, 70 }, { 200, 150, 70 }, { 190, 190, 80 }, { 90, 180, 90 },
{ 80, 170, 170 }, { 80, 120, 200 }, { 140, 90, 190 }, { 190, 90, 150 },
}
function ShinyPalette.tintFor(dex)
local hit = tintCache[dex]
if hit ~= nil then return hit or nil end
local spec = ShinyPalette.forDex(dex)
local fn = ShinyPalette.transform(spec)
if not fn then tintCache[dex] = false; return nil end
local sr, sg, sb, n = 0, 0, 0, 0
if spec.lut then
-- A lookup table answers only the colours that are IN it, so running
-- synthetic references through one returns them untouched and reports a
-- tint of exactly 1 -- i.e. no tint, for the five species whose shiny is
-- the most dramatic in the game. (A shiny Gyarados came out with an
-- ordinary blue pic for precisely this reason.) The table's own entries
-- are the right sample: they are what this Pokemon is actually made of.
for from, to in pairs(spec.lut) do
local fr, fg, fb = floor(from / 65536) % 256, floor(from / 256) % 256,
from % 256
local tr, tg, tb = floor(to / 65536) % 256, floor(to / 256) % 256,
to % 256
-- guard the near-black entries: a ratio against 2 is noise, and a
-- handful of them would swamp the mean
if fr > 24 and fg > 24 and fb > 24 then
sr = sr + tr / fr
sg = sg + tg / fg
sb = sb + tb / fb
n = n + 1
end
end
end
-- A slide: measure it against the colour this Pokemon is mostly MADE of.
--
-- Averaging over a balanced set of references does not work, and the
-- reason is worth keeping: a hue rotation moves red toward cyan and cyan
-- toward red, so over a symmetric wheel the ratios cancel and every
-- species reports a tint of 1. Charizard and Ponyta both came back with no
-- tint at all that way. One real body colour, rotated, is the whole
-- answer.
if n == 0 and spec.dom then
local dr = floor(spec.dom / 65536) % 256
local dg = floor(spec.dom / 256) % 256
local db = spec.dom % 256
if dr > 12 and dg > 12 and db > 12 then
local r, g, b = fn(dr, dg, db)
sr, sg, sb, n = r / dr, g / dg, b / db, 1
end
end
if n == 0 then
for _, c in ipairs(REFS) do
local r, g, b = fn(c[1], c[2], c[3])
sr = sr + r / c[1]
sg = sg + g / c[2]
sb = sb + b / c[3]
n = n + 1
end
end
local t = {
max(TINT_FLOOR, min(1, sr / n)),
max(TINT_FLOOR, min(1, sg / n)),
max(TINT_FLOOR, min(1, sb / n)),
}
-- a tint that came out as no tint at all is worse than none: it costs a
-- colour-hook wrap per draw and changes nothing
if t[1] > 0.995 and t[2] > 0.995 and t[3] > 0.995 then
tintCache[dex] = false
return nil
end
tintCache[dex] = t
return t
end
-- A transform for PALETTE colours rather than texture texels.
--
-- The two are not the same job, and using the texel transform on a palette
-- quietly does nothing for five species. A lookup table answers only the
-- colours that are in it -- the ones its model is painted with -- and the
-- engine's ADVANCED palettes are a different set of colours entirely
-- (BLUEMON's blue is not any blue on the Gyarados model). Asked to shift a
-- palette, the table therefore returns it unchanged, and the most dramatic
-- shiny in the game comes out identical.
--
-- So: slide species use the slide, which is defined on all colours. Table
-- species fall back to their tint multiplier, which IS derived from the
-- table and does carry its direction.
function ShinyPalette.paletteTransform(dex)
local spec = ShinyPalette.forDex(dex)
if not spec then return nil end
if not spec.lut then return ShinyPalette.transform(spec) end
local t = ShinyPalette.tintFor(dex)
if not t then return nil end
return function(r, g, b)
return floor(min(255, r * t[1]) + 0.5),
floor(min(255, g * t[2]) + 0.5),
floor(min(255, b * t[3]) + 0.5)
end
end
-- ------- the pass over one species' whole texture array
-- Recolour `textures` in place, skipping the ones that must not move.
--
-- Two exclusions, both load-bearing:
--
-- generated / index == -1 StadiumFx's flipbook frames -- flames, beams,
-- sparks. A shiny Pokemon has a shiny hide and
-- an ordinary fire; tinting the attack effects
-- would read as a bug. This marker exists ONLY
-- here, which is why the recolour lives at
-- extraction (see the header).
-- w or h of zero a degenerate slot with nothing to transform.
--
-- Returns the number of textures actually touched, so the caller can tell a
-- species that recoloured from one that silently did not.
function ShinyPalette.recolorTextures(textures, spec)
local fn = ShinyPalette.transform(spec)
if not fn then return 0 end
local touched = 0
for i = 1, #textures do
local t = textures[i]
local skip = t.generated == true or t.index == -1
or not t.w or not t.h or t.w == 0 or t.h == 0
if not skip and t.rgba and #t.rgba > 0 then
t.rgba = ShinyPalette.recolorTexels(t.rgba, fn)
touched = touched + 1
end
end
return touched
end
return ShinyPalette
+227
View File
@@ -0,0 +1,227 @@
-- Where a shiny SHOWS on the flat art: the battle pics and the status page.
--
-- The Stadium models carry genuinely recoloured texels (see ShinyPalette and
-- the extraction). Everything drawn as a Game Boy pic cannot, because the
-- engine bakes a species palette into an image cache keyed by path and
-- palette name -- a cache with no notion of WHICH Rattata is being drawn. So
-- the flat side is answered two ways:
--
-- the battle pic tinted at draw time, per side, with the multiply
-- ShinyPalette.tintFor derives from that species' own
-- shiny slide. Under ADVANCED (`redpp`, the pokered-gbc
-- colour pack) the pic is at its most colourful and the
-- tint reads clearly; under the DMG modes there is
-- barely any colour to shift, which is why the status
-- page also carries a plain, mode-proof MARK.
-- the status page a star beside the level, drawn in the engine's own
-- GB pixel grid so it is palette-processed like every
-- other pixel on the screen rather than floating over
-- the finished frame.
--
-- Both are monkeypatches, idempotent by sentinel, the pattern the rest of
-- this mod uses (OverworldBattle.install, Stadium.install). The summary
-- screen has no hook at all -- there is no `ui.summary.*` anywhere in the
-- engine -- so a wrap is the only route to it, and it is deliberately a thin
-- one: draw the engine's screen, then add one glyph.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Shiny = V.require("Shiny")
local ShinyPalette = V.require("ShinyPalette")
local ShinyUI = {}
-- ------- the tint, applied to one draw
--
-- Lifted in shape from OverworldBattle.withTint, and for the same reason it
-- exists there: the pics layer sets its own colour many times over as it
-- draws (the faint slide's fade, the damage blink), so the way to tint the
-- result without clobbering any of that is to multiply every colour it sets
-- on its way past. Restored unconditionally, including on error, because a
-- leaked setColor would tint the entire rest of the frame.
function ShinyUI.withTint(tint, fn, ...)
if not tint then return fn(...) end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
local gfx = love.graphics
local setColor = gfx.setColor
gfx.setColor = function(cr, cg, cb, ca, ...)
if type(cr) == "table" then
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
cr[4] }, cg, ...)
end
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
end
local ok, err = pcall(fn, ...)
gfx.setColor = setColor
setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
end
-- The tint for a mon, or nil when it is not shiny or we have no colours.
function ShinyUI.tintFor(mon, data)
if not (mon and Shiny.isShiny(mon)) then return nil end
local def = data and data.pokemon and data.pokemon[mon.species]
local dex = def and def.dex
if not dex then return nil end
return ShinyPalette.tintFor(dex)
end
-- ------- the star
--
-- Drawn as rectangles rather than as a font character because the Game Boy
-- charmap has no star, and a letter would read as a typo. Four spokes and a
-- centre, in the same near-black the screen's text uses, so the palette pass
-- treats it exactly like a glyph -- under ADVANCED and under every DMG mode
-- alike.
--
-- Seven pixels square, which is the largest that fits the gap beside the
-- level without touching the DrawLineBox bracket at column 19.
function ShinyUI.drawStar(px, py)
local g = love.graphics
local r, gg, b, a = g.getColor()
g.setColor(0, 0, 0, 1)
-- vertical, horizontal, then the four diagonal nubs
g.rectangle("fill", px + 3, py, 1, 7)
g.rectangle("fill", px, py + 3, 7, 1)
g.rectangle("fill", px + 1, py + 1, 1, 1)
g.rectangle("fill", px + 5, py + 1, 1, 1)
g.rectangle("fill", px + 1, py + 5, 1, 1)
g.rectangle("fill", px + 5, py + 5, 1, 1)
g.setColor(r, gg, b, a)
end
-- ------- install
function ShinyUI.install()
ShinyUI.installSummary()
ShinyUI.installBattlePics()
end
-- The status page. Wraps the draw and adds the star afterwards, so the
-- engine's own layout is untouched and a layout change upstream costs us
-- the glyph's position and nothing else.
function ShinyUI.installSummary()
local ok, SummaryMenu = pcall(require, "src.ui.SummaryMenu")
if not ok or type(SummaryMenu) ~= "table" then return end
if SummaryMenu.dramaticShapeShiny then return end
local inner = SummaryMenu.draw
if type(inner) ~= "function" then return end
function SummaryMenu:draw(...)
local out = { inner(self, ...) }
-- page 1 only: page 2 wipes the block the level sits in
-- (status_screen.asm ClearScreenArea over (9,2)), so a mark left there
-- would be half-erased by the engine's own clear.
if self.page == 1 and Shiny.isShiny(self.mon) then
-- beside PrintLevel at (14,2): column 13, row 2, in the gap the
-- level's own leading space leaves
pcall(ShinyUI.drawStar, 104, 17)
end
return unpack(out)
end
-- The summary PIC, through its PALETTE.
--
-- Recolouring the sprite's pixels here does NOT work, and it is worth
-- writing down why rather than leaving it to be re-attempted: the summary
-- art is four-shade DMG grey, and the screen's colour is applied
-- afterwards by the palette pass over the finished frame. Whatever RGB is
-- put in the ImageData is remapped away by it. The colour of that pic
-- lives in the palette and nowhere else, so the palette is what has to
-- move. (Tried it, shot it, reverted it.)
--
-- This is the ADVANCED-palette answer, and it is better than a multiply:
-- SetPal_StatusScreen hands the pic zone the species palette
-- (PaletteFX.monPal), and sgbPalettes is a method on the MENU, so unlike
-- the battle pic's image cache it knows which individual is on screen.
-- Running those four colours through the species' own shiny transform
-- gives the summary a genuinely recoloured Pokemon -- brightening
-- included, which a draw-colour multiply cannot do.
--
-- Only ZONE entries are touched. The first palette the engine returns is
-- the whole-screen HP-bar one, and rotating that would recolour the text.
local innerPal = SummaryMenu.sgbPalettes
if type(innerPal) == "function" then
function SummaryMenu:sgbPalettes(game, ...)
local out = innerPal(self, game, ...)
if type(out) ~= "table" or not Shiny.isShiny(self.mon) then return out end
local def = game and game.data and game.data.pokemon
and game.data.pokemon[self.mon.species]
local fn = def and def.dex
and ShinyPalette.transform(ShinyPalette.forDex(def.dex))
if not fn then return out end
for _, z in ipairs(out) do
if type(z) == "table" and z.w and z.h and type(z.colors) == "table" then
-- copied, never mutated in place: monPal hands back the dataset's
-- own palette table, and writing through it would recolour every
-- Pokemon of the species everywhere for the rest of the process
local cols = {}
for i, c in ipairs(z.colors) do
if type(c) == "table" and c[1] then
local r, g, b = fn(c[1], c[2], c[3])
cols[i] = { r, g, b }
else
cols[i] = c
end
end
z.colors = cols
end
end
return out
end
end
SummaryMenu.dramaticShapeShiny = true
end
-- The battle pics. The engine's pic layer is reached through
-- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a
-- per-side tint has to bracket each side separately, which is exactly what
-- OverworldBattle.sideTexture already does when it renders one side at a
-- time into its own canvas. That is where the tint belongs on the 3D path;
-- this wrap covers the FLAT path, where the engine draws the battle itself.
function ShinyUI.installBattlePics()
local ok, BattleState = pcall(require, "src.battle.BattleState")
if not ok or type(BattleState) ~= "table" then return end
if BattleState.dramaticShapeShinyPics then return end
local inner = BattleState.drawPicsLayer
if type(inner) ~= "function" then return end
function BattleState:drawPicsLayer(...)
-- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod
-- is rendering one side into its own canvas it brackets that draw with
-- that side's own tint (OverworldBattle.sideTexture). Tinting again here
-- would square it. Asked as a question rather than left to install
-- order, because both wraps are installed from main.lua and whichever
-- ran first would otherwise silently decide the outcome.
local okOw, Ow = pcall(V.require, "OverworldBattle")
if okOw and Ow and Ow.texturingSide and Ow.texturingSide() then
return inner(self, ...)
end
-- Both sides at once, so when they disagree the tint cannot be applied
-- per-side here without splitting the engine's own draw. When only ONE
-- side is shiny we tint the whole layer by it: the other side's pic is
-- dimmed slightly, which is far less wrong than a shiny drawn in its
-- ordinary colours -- and when both are shiny each gets the mean.
local data = self.game and self.game.data
local a = self.player and ShinyUI.tintFor(self.player.mon, data)
local b = self.enemy and ShinyUI.tintFor(self.enemy.mon, data)
local tint = a or b
if a and b then
tint = { (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2 }
end
if not tint then return inner(self, ...) end
local args = { ... }
local out
ShinyUI.withTint(tint, function() out = { inner(self, unpack(args)) } end)
return unpack(out or {})
end
BattleState.dramaticShapeShinyPics = true
end
return ShinyUI
+818
View File
@@ -0,0 +1,818 @@
-- STADIUM battles: the two Pokemon as real 3D models.
--
-- The 3D-BTL row's two STADIUM rungs. OFF is the engine's own white battle
-- field; the 2D-3D rungs stand the GB's own pics up as quads
-- (BattleBillboard); STADIUM replaces those quads with the Pokemon Stadium
-- battle models -- skinned, animated, and playing the animation the move
-- being used actually calls for. A or B decides whether that happens on the
-- map or on two discs, and is the same choice on either pair of rungs.
--
-- The models come out of the Stadium ROM through model_extract, and are
-- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here
-- knows about the ROM; the pack is the interface.
--
-- ------- what this file is, and is not
--
-- It is the MODE: which species is out on each side, which animation the
-- fight is asking each of them for, whether the model or the flat pic is
-- standing in this frame, and the two draw calls. The arithmetic is
-- StadiumRig's, the file format is StadiumPack's, and one side's own state
-- is StadiumMon's.
--
-- It is not a rewrite of the staged battle. The arena is picked the same
-- way, the camera is solved the same way, the HUDs and the text box and the
-- move animations and the depth of field are all exactly what 2D-3D draws
-- -- because all of those are hung off the arena's CELLS, not off the
-- pics. Swapping what stands on a cell changes nothing about where the cell
-- projects to. That is why this is an option on the mode rather than a
-- second mode.
--
-- ------- declining, per Pokemon
--
-- Every gate here is per SIDE and per FRAME, not per battle:
--
-- no pack for that species, or its meshes would not build -> that side
-- falls back to its flat pic, and the other side keeps its model
--
-- the side is showing a TRAINER (the foe's class before the send-out,
-- the player's own back before "Go!") -> that is not a Pokemon and there
-- is no model for it; the pic stands, exactly as in 2D-3D
--
-- a SUBSTITUTE is up -> the engine replaces the pic with the mini doll,
-- which is the thing the player is being told is there. A model of the
-- Pokemon behind the doll would be a lie about the battle state.
--
-- So `covers` is asked per side per frame, and OverworldBattle renders a
-- billboard texture for exactly the sides it answers false for.
-- the mod namespace (see main.lua): V.require loads a sibling module
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 = {}
-- The stored values of the two 3D-BTL rungs that select this mode. Strings
-- rather than further booleans so an older save's `true` still means the
-- 2D-3D it was written for (see OverworldBattle.setting).
--
-- A the models on the MAP -- real ground, the map's own light and sky
-- B the models on two DISCS against the sky, with no map at all
--
-- Everything below is shared: which species is out, which animation the
-- fight is asking for, the skinning, the draw. The difference is entirely
-- in what the camera is pointed at, which is BattleScene's business and
-- StadiumStage's.
Stadium.VALUE = "stadium"
Stadium.VALUE_B = "stadiumB"
-- ------- the live pair
local session = nil -- nil when no staged fight is running
local function game()
return require("src.core.Game")
end
-- Whether the row is on this rung. Deliberately NOT gated on whether the
-- packs are installed: a mod folder without assets/stadium still cycles the
-- row, and each Pokemon declines on its own when its pack does not load --
-- which is one message on the console rather than a row that silently
-- refuses to move.
function Stadium.selected()
return Stadium.mode() ~= nil
end
-- "A", "B", or nil when the row is on neither stadium rung.
function Stadium.mode()
local OverworldBattle = V.require("OverworldBattle")
local value = OverworldBattle.setting:get()
if value == Stadium.VALUE then return "A" end
if value == Stadium.VALUE_B then return "B" end
return nil
end
-- Whether the fight is staged on the DISCS rather than on the map.
--
-- Not this file's question any more: the flat 2D-3D B rung stands the game's
-- own pics on the same two discs with no model anywhere in the frame, so the
-- stage and the actors are chosen separately (see OverworldBattle's ladder).
-- Kept as a forwarder because "are we on discs" is a fair thing to ask the
-- module named after the mode, and because the shot drivers ask it here.
function Stadium.discs()
return V.require("OverworldBattle").discs()
end
function Stadium.enabled()
if not Stadium.selected() then return false end
return Voxel3D.available()
end
-- A staged fight has begun on `arena`. Called from OverworldBattle.begin,
-- which is the one place that knows a fight is being staged at all.
function Stadium.begin(arena)
Stadium.finish()
if not Stadium.enabled() then return false end
-- a new fight gets its own first complaint: `reported` is a one-shot so the
-- console is not filled sixty times a second, but latched for the whole
-- process it would swallow every failure after the first one ever
Stadium.reported = false
session = {
arena = arena,
groundY = 0,
player = StadiumMon.new("player"),
enemy = StadiumMon.new("enemy"),
-- what each side has been TRANSFORMED into, if anything (see install)
transform = {},
-- sides that are going to collapse, but whose HP bar has not finished
-- emptying yet (see faintReady)
faintPending = {},
-- who was standing in each slot last frame, so a replacement is noticed
-- even when it is the same species (see update)
at = {},
}
return true
end
function Stadium.finish()
if not session then return end
session.player:release()
session.enemy:release()
session = nil
end
function Stadium.active()
return session ~= nil
end
-- ------- which species each side is showing
-- The National Dex number for a battler, which is the number the Stadium
-- packs are keyed by. The engine's species are string keys ("PIKACHU") and
-- carry their dex number on the definition, so this is one lookup rather
-- than a table of its own.
local function dexOf(species)
if not species then return nil end
local data = game() and game().data
local def = data and data.pokemon and data.pokemon[species]
return def and def.dex or nil
end
-- Whether this side is showing a TRAINER rather than a Pokemon.
local function showingTrainer(battle, side)
if side == "enemy" then
return (battle.showEnemyTrainer and battle.trainerPic) and true or false
end
return (battle.showPlayerBack and battle.playerBackPic) and true or false
end
-- Whether this side has anything on the field at all this frame.
--
-- Mirrors BattleState's own guards, the same way OverworldBattle.sideVisible
-- mirrors them for the flat cards: there is no seam that reports "the foe is
-- off screen right now", and a model left standing through a send-out or a
-- damage blink would be the one thing in the frame that ignored the battle.
-- ------- and the collapse gets to finish
--
-- A fainted Pokemon leaves the field when its pic does, which is the end of
-- the engine's slide -- SlideDownFaintedMonPic, seven rows two frames apart,
-- FOURTEEN frames of a 60 Hz clock. Under a quarter of a second.
--
-- The Stadium faint animations are nothing like that short. The briefest in
-- the set is 49 frames of a 30 Hz clock -- a second and two thirds -- the
-- median is 110 and the longest 230, which is nearly eight seconds. Held to
-- the pic's window every one of them was cut off inside its first fifth: the
-- Pokemon began to fall and vanished mid-fall, which is worse than not
-- animating at all, because the eye has been told something is happening and
-- then had it taken away.
--
-- So a model that is COLLAPSING stays until it has finished collapsing, and
-- the two timings stop being tied to each other. That is the whole of the
-- divergence: the slide is how long a flat pic takes to slide off the bottom
-- of a 160x144 frame, and it has nothing to say about how long it takes a
-- Gyarados to fall over.
--
-- Bounded at both ends rather than open-ended. It ends when the animation
-- does (StadiumMon.finished), not when the battle moves on -- so nothing is
-- left lying on the field for the rest of the fight -- and the side is reset
-- outright the moment a different battler stands in that slot (see update),
-- which is what stops the next Pokemon out of the ball arriving face down.
local function onField(battle, side, mon)
local battler = side == "player" and battle.player or battle.enemy
if not (battler and battler.sprite) then return false end
-- A model that is GROWING out of its ball is on the field by definition --
-- that is what the grow is -- even though the engine still calls the side
-- "sending out", because the flat pic it wrote that flag for does not
-- appear until the ball has finished opening and this one comes out with
-- it (see StadiumMon.GROW_TIME).
local growing = (mon and mon.grow) and true or false
if side == "enemy" then
if battle.enemyHidden then return false end
if battle.enemySendingOut and not growing then return false end
else
if battle.safari or battle.demo then return false end
if battle.sendingOut and not growing then return false end
-- ------- and not before the battle has even opened
--
-- The player's Pokemon is not out during the INTRO. Every other guard
-- here is a field the engine sets once the battle is running, and during
-- the opening none of them is set yet: `showPlayerBack` is still nil
-- (BattleState assigns it further in, when the back pic is built),
-- `playerBackPic` is nil with it, and `sendingOut` does not go true until
-- the ball is actually thrown. So the whole opening read as "this
-- Pokemon is standing on the field" and the model was drawn through it --
-- two and a half seconds of it, on its tile, playing its standby loop,
-- before the trainer sprite it is supposed to be hiding behind had even
-- appeared. It then vanished when that sprite arrived and came back with
-- its entrance when the ball opened, so the first Pokemon of a battle
-- appeared, left and arrived again.
--
-- A SWITCH has no intro, which is why a switch always looked right and
-- was the thing worth comparing against.
--
-- Gated on the PHASE rather than on a flag latched at the send-out: a
-- latch that never fires (a link battle, a script pushing a battle
-- straight to the menu) would hide the Pokemon for good, and being wrong
-- in that direction is far worse than the two seconds this fixes.
if battle.phase == "intro" then return false end
end
local ok, hidden = pcall(battle.fxHidden, battle, battler)
if ok and hidden then return false end
-- ------- FLY and DIG: the Pokemon that is not there
--
-- `fxHidden` above is the damage BLINK and nothing else. The other way a
-- Pokemon leaves the screen -- the important one -- is the engine's
-- per-battler pic program, `picFx`, and that is where the two-turn moves
-- live: FLY runs SE_SLIDE_MON_OFF and DIG SE_SLIDE_MON_DOWN on the charge
-- turn, each a 19-24 frame slide that ENDS by setting `hidden`, and the
-- release turn puts the pic back through SE_SLIDE_MON_UP /
-- SE_SHOW_MON_PIC. Every other vanishing act is the same field: the user
-- of Explosion, a Pokemon that has been Teleported away.
--
-- Without this the model simply stood on its tile while the game said it
-- was underground -- and said it in the strongest way it has, by making
-- every attack aimed at it miss. That is the one thing in the frame
-- contradicting the battle it is part of.
--
-- Read as the engine's own answer rather than as a list of moves: this
-- mode's whole method is to let the battle decide and follow it, and a
-- table of move ids here would be a second place for the same facts to
-- live and would go stale against a mod that adds a third one.
--
-- The engine's slide is 19-24 frames, so the model plays the opening of
-- its own FLY or DIG animation while the pic slides and is gone when the
-- pic is. It is NOT held to the end of that animation the way a collapse
-- is (see below), and the difference is not an oversight: the Stadium
-- animations are authored as the WHOLE move -- Charizard's DIG is 3.83
-- seconds of burrow, emerge and hit -- because Stadium plays it in one
-- turn. Gen 1 splits it across two, so cutting at the engine's own hide
-- shows the burrowing and holds the strike back for the turn it lands on,
-- which is the right half of the animation for the turn being played.
local pf = battle.picFx and battle.picFx[battler]
if pf and pf.hidden then return false end
if battler.fainted then
local okF, sliding = pcall(battle.fxFaintActive, battle, battler)
if okF and sliding then return true end
-- the pic has finished sliding away; the model has not finished falling
return (mon and mon.state == "faint" and not mon:finished()) and true
or false
end
return true
end
Stadium._onField = onField
-- Whether the 3D model is standing in for this side's pic this frame. The
-- one question OverworldBattle asks, and the answer that decides whether a
-- billboard texture gets rendered for that side at all.
function Stadium.covers(battle, side)
if not (session and battle) then return false end
local mon = session[side]
if not (mon and mon.rig) then return false end
if showingTrainer(battle, side) then return false end
local battler = side == "player" and battle.player or battle.enemy
-- the substitute doll is what the player is being shown is out there
if battler and battler.substituteHP then return false end
return true
end
-- ------- the collapse waits for the bar
--
-- `onFaint` runs the instant HP reaches zero, which is NOT when a Pokemon
-- falls over. The engine queues the collapse -- the slide, the cry, the
-- "fainted!" line -- to run after the move animation and the HP-bar drain
-- (BattleState.onFaint's own comment), and the drain takes real frames: a
-- 150 HP mon's bar walks down over some four seconds.
--
-- So asking for the faint animation at `onFaint` played it against a bar
-- that was still emptying: the Pokemon lay down, and then its health went on
-- draining above the corpse. What the player reads as the moment of death is
-- the bar hitting zero, and that is what this waits for.
--
-- `shownHP` is the engine's own bar position (BattleState.stepHPDrain walks
-- it toward mon.hp a point at a time), so this is not a guess at the timing
-- -- it is the same number the bar is drawn from.
local function faintReady(battler)
if not battler then return false end
-- nothing is animating the bar for this battler: there is nothing to wait
-- for, and waiting forever would mean never collapsing at all
if battler.shownHP == nil then return true end
return battler.shownHP <= 0
end
-- Whether a pending collapse is still owed. A switch, a revive or a battler
-- that was replaced under us drops it rather than firing late at whoever is
-- standing there now.
local function faintStillDue(battler)
return (battler and battler.faintQueued
and battler.mon and (battler.mon.hp or 0) <= 0) and true or false
end
-- named for the suite: these timing rules are the whole of what decides when
-- a Pokemon falls and when it goes, and they are testable without a graphics
-- context where the mode itself is not
Stadium._faintReady = faintReady
Stadium._faintStillDue = faintStillDue
-- ------- per frame
--
-- Runs from OverworldBattle.update, before the pics are rendered and before
-- the scene is drawn: what this decides is exactly which sides need a pic.
function Stadium.update(dt, battle, groundY)
if not session then return end
session.groundY = groundY or session.groundY or 0
if not battle then return end
local arena = session.arena
for _, side in ipairs({ "enemy", "player" }) do
local mon = session[side]
local battler = side == "player" and battle.player or battle.enemy
local dex = nil
if battler and not showingTrainer(battle, side) then
dex = session.transform[side] or dexOf(battler.mon and battler.mon.species)
end
-- A DIFFERENT POKEMON IS IN THIS SLOT. Normally that shows up as a
-- change of species and setSpecies rebuilds everything -- but a trainer
-- who leads with two Rattata sends the second one out onto the first
-- one's dex number, so nothing downstream would notice. What it would
-- inherit is the state, and the state after a faint is `faint`, which
-- refuses every request there is (see StadiumMon.request -- a faint is
-- meant to be final). The new Pokemon would arrive lying on the ground.
--
-- The battler TABLE is the identity here rather than the species or the
-- mon: it is the slot's occupant, and the engine replaces it on a switch,
-- a send-out and a new battle alike.
if session.at[side] ~= battler then
session.at[side] = battler
-- 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
if not faintStillDue(battler) then
session.faintPending[side] = nil
elseif faintReady(battler) then
session.faintPending[side] = nil
if mon and mon.rig then mon:request("faint") end
end
end
-- 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). 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
if mon.rig then
-- ------- the ball is opening: start growing out of it
--
-- The POOF is the ball coming apart, and it is where a Pokemon should
-- begin to exist -- not 27 frames later when the engine starts scaling
-- up the flat pic it was written for. Only for a side the battle says
-- is actually sending out, so the same animation played at a thrown
-- Poke Ball (a capture attempt, which aims it at the FOE) cannot start
-- the wrong Pokemon growing.
local poof = (battle.animPlaying
and battle.animName == "POOF_ANIM") and true or false
local sending = (side == "player") and battle.sendingOut
or battle.enemySendingOut
if poof and sending and mon:beginGrow() then
-- and the arrival animation with it, so the whole thing is one
-- performance rather than a grow followed by a flourish
mon:request("entrance")
end
-- how big it is drawn. Its own ramp while it is growing (see
-- StadiumMon.growScale); the engine's three-step one otherwise, which
-- still covers a send-out that never showed a poof.
if mon.grow then
mon.scale = mon:growScale()
elseif mon.grewOwn then
mon.scale = 1
else
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]
local other = arena[side == "player" and "enemy" or "player"]
if cell and other then
-- posed and skinned inside the same guard the draws use: this is
-- where a bad track or a released texture is first touched, and a
-- throw here would take the OTHER side's update with it (the
-- caller wraps this whole function in one pcall)
Stadium.guard(side, mon, "build", function()
mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
other[1] - cell[1],
other[2] - cell[2])
mon:build()
end)
else
mon.model_matrix = nil
end
else
mon.model_matrix = nil
end
end
end
Stadium.debug(dt)
end
-- ------- the draws
--
-- Both take the pass as they find it: this is called from inside
-- BattleScene's own beginScene/endScene window (and, in a headset, from
-- VoxelScene's), so the camera, the shadow map, the hour's tint and the hit
-- flash are all already set. StadiumRig turns the wireframe and the glass
-- mask off around its own draws and puts them back.
-- ------- one model going wrong is not both
--
-- These two draws used to be a bare loop inside the caller's single pcall,
-- which had two consequences and both were bad. A throw on the FIRST side
-- skipped the second, so one broken Pokemon took its opponent off the screen
-- with it. And nothing recorded that it had happened, so the same throw came
-- back every frame for the rest of the fight -- the mode's own fallback (that
-- side draws its flat pic instead) was sitting right there and never reached,
-- because falling back needs somebody to decide the model is not working.
--
-- So each side is drawn inside its own pcall, and a side that throws is
-- RETIRED: its rig is released, which is exactly the state a species with no
-- pack is in, and OverworldBattle renders a billboard for it from the next
-- frame on. The fight carries on with a flat Pokemon instead of a missing
-- one, which is the difference the player actually sees.
-- On the TABLE rather than a local, because Stadium.update calls it and sits
-- above this line: a local would still be nil there.
function Stadium.guard(side, mon, what, fn)
local ok, err = pcall(fn)
if ok then return true end
Stadium.report(err)
-- release rather than merely hide: the rig holds meshes and texture
-- references, and whatever went wrong with them is not going to be better
-- next frame. setSpecies rebuilds from scratch if this Pokemon is sent out
-- again later.
if mon.rig then pcall(mon.release, mon) end
mon.rig, mon.visible, mon.model_matrix = nil, false, nil
if session then session.broken = session.broken or {} end
if session then session.broken[side] = what end
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
local mon = session[side]
if mon.rig and mon.visible and mon.model_matrix then
Stadium.guard(side, mon, "draw", function()
mon.rig:draw(mon.model_matrix, pull)
end)
end
end
end
-- The same models as the SUN sees them, so a Pokemon throws the shadow of
-- the pose it is actually in -- an outstretched wing puts an outstretched
-- wing on the ground.
function Stadium.cast(shadowMap)
if not session then return end
for _, side in ipairs({ "enemy", "player" }) do
local mon = session[side]
if mon.rig and mon.visible and mon.model_matrix then
Stadium.guard(side, mon, "cast", function()
mon.rig:caster(shadowMap, mon.model_matrix)
end)
end
end
end
-- Which state a side's model is playing, or nil. Named for the shot drivers:
-- checking that an animation starts on the right FRAME is an ordering
-- question, and a screenshot cannot answer one.
function Stadium.animOf(side)
if not session then return nil end
local mon = session[side]
return mon and mon.state or nil
end
-- Whether this side's model is actually being drawn this frame. Named for
-- the shot drivers alongside animOf: "how long does it stay" is a span, and
-- a screenshot taken at one moment has no span in it.
function Stadium.showing(side)
if not session then return false end
local mon = session[side]
return (mon and mon.visible) and true or false
end
-- How big this side's model is being drawn this frame, 0..1 -- the send-out
-- grow. Named for the shot drivers: a ramp is a curve over time and a
-- screenshot has one point of it.
function Stadium.scaleOf(side)
if not session then return nil end
local mon = session[side]
return mon and mon.scale or nil
end
-- How wide the Pokemon on `side` stands, in world pixels, or nil when there
-- is not one. What STADIUM B sizes that side's platform to (StadiumStage).
function Stadium.footprint(side)
if not session then return nil end
local mon = session[side]
if not (mon and mon.model) then return nil end
local r = mon:worldRadius()
return (r > 0) and r or nil
end
-- Whether anything at all is standing this frame -- what the shadow
-- signature keys on alongside the pics' own token.
function Stadium.standing()
if not session then return false end
return (session.player.visible or session.enemy.visible) and true or false
end
-- ------- what the fight asks for
--
-- The animation state machine is driven from four points in the engine's
-- own battle, and each is a wrap rather than a rewrite: the inner function
-- runs exactly as it always did and this reads what went past.
local function sideOf(battle, battler)
if not (session and battler) then return nil end
if battler == battle.player then return "player" end
if battler == battle.enemy then return "enemy" end
return nil
end
local function ask(battle, battler, state, animIndex, auxIndex)
local side = sideOf(battle, battler)
if not side then return end
local mon = session[side]
if mon and mon.rig then mon:request(state, animIndex, auxIndex) end
end
function Stadium.install()
local BattleState = require("src.battle.BattleState")
if BattleState.dramaticShapeStadiumHook then return end
BattleState.dramaticShapeStadiumHook = true
-- THE ATTACK. performMove is the one place a move is actually used, and
-- the move's own `index` is the Gen 1 move id the Stadium tables are
-- keyed by -- so the species' own animation for that move comes straight
-- out of the pack, with no name mapping and no per-move code.
local innerMove = BattleState.performMove
function BattleState:performMove(user, target, moveInst, isCalled)
if session then
local side = sideOf(self, user)
local mon = side and session[side]
if mon and mon.rig then
local okDef, def = pcall(self.moveDef, self, moveInst)
local index = okDef and def and def.index or nil
if not (index and mon:attack(index)) then
-- a move the table has nothing for still swings: the generic
-- attack is what the species' own reaction slot resolves to
mon:request("attack")
end
end
end
return innerMove(self, user, target, moveInst, isCalled)
end
-- THE HIT is deliberately NOT hooked. There is no damage reaction in this
-- set to play -- what looked like one is the species' default attack (see
-- StadiumMon's STATES), which is why being hit used to look like swinging.
-- The engine's own flash, pic blink and bar drain are what say "that hurt",
-- and they are already in the frame.
-- THE FAINT. Held on its last frame rather than looped (see StadiumMon's
-- STATES), because a Pokemon that collapses and then stands back up
-- while the message is still on screen is worse than no animation.
--
-- RECORDED HERE, PLAYED LATER. This runs the moment HP reaches zero, which
-- is several seconds before the Pokemon is supposed to fall over -- the
-- engine queues the collapse behind the move animation and the HP-bar
-- drain. Marking the side and letting Stadium.update fire it when the bar
-- empties is what keeps the two together (see faintReady).
local innerFaint = BattleState.onFaint
function BattleState:onFaint(battler)
if session and not (battler and battler.faintQueued) then
local side = sideOf(self, battler)
if side and session.faintPending then
session.faintPending[side] = true
end
end
return innerFaint(self, battler)
end
-- THE ENTRANCE. startGrowIn is the send-out: the ball opens, the pic
-- scales up over twelve frames, and the model plays the animation the
-- battle system's own entrance slot names.
local innerGrow = BattleState.startGrowIn
function BattleState:startGrowIn(battler)
if session then
-- unless the model is already on its way out of the ball, in which
-- case the entrance started with the POOF (see update) and asking
-- again here would restart it a third of a second in
local side = sideOf(self, battler)
local mon = side and session[side]
if not (mon and mon.grow) then ask(self, battler, "entrance") end
end
return innerGrow(self, battler)
end
-- TRANSFORM. The engine records a transform by swapping the battler's
-- sprite and nothing else, so this is the only seam that reports one --
-- and it reports the side, which is all that is needed to point that
-- side's model at the copied species. Cleared when a side's own species
-- changes under it (a switch, or the next battle).
local innerSpecies = BattleState.speciesSprite
function BattleState:speciesSprite(species, isPlayerSide)
if session then
session.transform[isPlayerSide and "player" or "enemy"] = dexOf(species)
end
return innerSpecies(self, species, isPlayerSide)
end
-- and a switch or a send-out ends any transform on that side
local innerSwitch = BattleState.resolveSwitch
function BattleState:resolveSwitch(newMon)
if session then session.transform.player = nil end
return innerSwitch(self, newMon)
end
end
-- ------- when a draw goes wrong
--
-- The draw and the shadow cast are both called through a pcall, because a
-- throw inside the scene pass would hand the whole voxel mode to Pipelines'
-- guard and retire it for the session. Swallowed silently, though, a broken
-- model is indistinguishable from an invisible one -- so the first failure
-- of a battle says so, once, and the rest of the fight carries on without
-- it.
Stadium.reported = false
function Stadium.report(err)
if Stadium.reported then return end
Stadium.reported = true
V.mod.log:warn("stadium: a model failed and was retired for this battle: "
.. "%s -- that Pokemon falls back to its flat battle pic, "
.. "and its opponent is unaffected", tostring(err))
end
-- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which
-- is how "nothing is on screen" gets told apart from "nothing was asked
-- for". Read through pcall: the loader's sandbox does not hand a mod `os`,
-- and a diagnostic must never be why the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_STADIUM_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end
local debugAt = 0
function Stadium.debug(dt)
if not (DEBUG and session) then return end
debugAt = debugAt + (dt or 0)
if debugAt < 1 then return end
debugAt = 0
for _, side in ipairs({ "enemy", "player" }) do
local mon = session[side]
local m = mon.model_matrix
V.mod.log:info("stadium %s: dex=%s rig=%s visible=%s anim=%s t=%.2f "
.. "height=%.1f at=%s",
side, tostring(mon.species), tostring(mon.rig ~= nil),
tostring(mon.visible), tostring(mon.anim), mon.time or 0,
mon.model and mon:worldHeight() or 0,
m and ("%.0f,%.0f,%.0f"):format(m[4], m[8], m[12]) or "-")
end
end
function Stadium.invalidate()
if session then
session.player:release()
session.enemy:release()
end
StadiumPack.invalidate()
-- the discs are a mesh and a texture like anything else, and a graphics
-- context that went away took them with it
pcall(function() V.require("StadiumStage").invalidate() end)
end
return Stadium
+750
View File
@@ -0,0 +1,750 @@
-- STADIUM battles: turning the ROM into assets/stadium/NNN.dsm.
--
-- The Lua half of tools/stadium_pack.py: measure the bind pose, decide
-- whether a species' standby loop can be trusted, and write the packed file.
-- Together with StadiumRom, StadiumFragment and StadiumFx this is everything
-- between `baserom.z64` and a Pokemon standing on a battle tile.
--
-- The Python remains the ORACLE. tests/stadium_extract_test.lua runs this
-- over the same ROM and requires all 151 files to come out byte for byte
-- identical to what the packer writes. That is a strong test in a way a unit
-- test of any one function here would not be: every rounding mode, every
-- iteration order, every off-by-one in an index shows up as a differing byte,
-- and there are thirty-four megabytes of them.
--
-- ------- stepped, not blocking
--
-- `StadiumBuild.job()` returns a coroutine-backed job that does one species
-- per `step()`, so the caller can draw a progress bar between them
-- (StadiumInstall). A species is a few tens of milliseconds; the whole set is
-- around half a minute, which is far too long to spend inside one frame and
-- perfectly fine spread across a loading screen.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumRom = V.require("StadiumRom")
local StadiumFragment = V.require("StadiumFragment")
local StadiumFx = V.require("StadiumFx")
local ShinyPalette = V.require("ShinyPalette")
local StadiumBuild = {}
local floor = math.floor
local sin, cos = math.sin, math.cos
local pi = math.pi
local char = string.char
local concat = table.concat
local frexp = math.frexp
local roundHalfEven = StadiumFragment.roundHalfEven
-- The battle system's fixed context slots, in slot order from 165. The mod
-- indexes this list by POSITION, so the ORDER is the format's contract and
-- has to stay identical to StadiumPack.CONTEXT and to the packer's CONTEXTS.
StadiumBuild.CONTEXTS = {
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
"struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
"reaction_180", "reaction_181", "reaction_182", "entrance_alt",
"idle_return",
}
-- Which context name wins when several claim the same animation.
local NAME_PREF = { "idle", "attack_default", "faint", "entrance",
"struggle", "flinch" }
local N_MOVES = StadiumRom.N_MOVES
local CTX_BASE = 165
local NONE16 = 0xFFFF
-- ------- the bind pose
-- The game's rotation as a 3x3, rows first (src/F420.c func_8000F730):
-- Rx*Ry*Rz in row-vector form.
local function quatBasis(r)
local sx, cx = sin(r[1] / 32768 * pi), cos(r[1] / 32768 * pi)
local sy, cy = sin(r[2] / 32768 * pi), cos(r[2] / 32768 * pi)
local sz, cz = sin(r[3] / 32768 * pi), cos(r[3] / 32768 * pi)
return { cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz },
{ cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz },
{ -sy, sx * cy, cx * cy }
end
-- 3x4 (three rotation rows plus a translation column) times the same.
local function matMul(a, b)
local out = {}
for r = 1, 3 do
local ar = a[r]
out[r] = {
ar[1] * b[1][1] + ar[2] * b[2][1] + ar[3] * b[3][1],
ar[1] * b[1][2] + ar[2] * b[2][2] + ar[3] * b[3][2],
ar[1] * b[1][3] + ar[2] * b[2][3] + ar[3] * b[3][3],
ar[1] * b[1][4] + ar[2] * b[2][4] + ar[3] * b[3][4] + ar[4],
}
end
return out
end
-- One component of one bone's t/r/s at a frame. The extractor's own shape: a
-- bare number when the component holds still for the whole animation, one
-- number a frame when it does not.
local function component(comps, i, frame, fallback)
if comps == nil then return fallback end
local c = comps[i]
if type(c) == "table" then
local n = #c
if n == 0 then return fallback end
return c[frame % n + 1]
end
return c
end
-- The bone TRS an animation holds at `frame`, rest where it is silent.
local function animSample(bones, anim, frame)
local tracks = anim.tracks
return function(i)
local b = bones[i]
local tr = tracks[i]
if not tr then return b.t, b.r, b.s end
return { component(tr.t, 1, frame, b.t[1]),
component(tr.t, 2, frame, b.t[2]),
component(tr.t, 3, frame, b.t[3]) },
{ component(tr.r, 1, frame, b.r[1]),
component(tr.r, 2, frame, b.r[2]),
component(tr.r, 3, frame, b.r[3]) },
{ component(tr.s, 1, frame, b.s[1]),
component(tr.s, 2, frame, b.s[2]),
component(tr.s, 3, frame, b.s[3]) }
end
end
local function restSample(bones)
return function(i)
local b = bones[i]
return b.t, b.r, b.s
end
end
-- Every bone's draw matrix at one instant, as 3x4 rows.
--
-- The game keeps bone scale OUT of the matrix chain: it accumulates in its own
-- stack, a bone's local translation is pre-multiplied by the PARENT's
-- accumulated scale, and the bone's own accumulated scale is applied to the
-- finished matrix at draw time.
--
-- Two chains, and the distinction is the whole point: `pivot` is the
-- rotation/translation a CHILD inherits, and the draw matrix is that with the
-- bone's own accumulated scale applied on the right. Folding the scale into
-- the chain instead applies every ancestor's scale twice -- which is exactly
-- the multiplicative propagation glTF has and the game does not.
local function bindMatrices(bones, sample)
sample = sample or restSample(bones)
local pivot, draw, acc = {}, {}, {}
local IDENT = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 } }
for i = 1, #bones do
local bt, br, bs = sample(i)
local p = bones[i].parent
local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
local pm = (p >= 0) and pivot[p + 1] or IDENT
local r1, r2, r3 = quatBasis(br)
local m = matMul(pm, {
{ r1[1], r1[2], r1[3], bt[1] * pa[1] },
{ r2[1], r2[2], r2[3], bt[2] * pa[2] },
{ r3[1], r3[2], r3[3], bt[3] * pa[3] },
})
local a = { pa[1] * bs[1], pa[2] * bs[2], pa[3] * bs[3] }
acc[i] = a
pivot[i] = m
-- scale on the right: the bone's own space, so it cannot reach children
draw[i] = {
{ m[1][1] * a[1], m[1][2] * a[2], m[1][3] * a[3], m[1][4] },
{ m[2][1] * a[1], m[2][2] * a[2], m[2][3] * a[3], m[2][4] },
{ m[3][1] * a[1], m[3][2] * a[2], m[3][3] * a[3], m[3][4] },
}
end
return draw
end
StadiumBuild.bindMatrices = bindMatrices
StadiumBuild.animSample = animSample
-- The axis-aligned box the whole model occupies under `mats`, in game units
-- after the model_root scale.
local function poseBox(data, mats)
local root = data.rootScale[1]
local lo1, lo2, lo3 = 1e30, 1e30, 1e30
local hi1, hi2, hi3 = -1e30, -1e30, -1e30
for _, prim in ipairs(data.prims) do
local pos, skin = prim.pos, prim.skin
for i = 1, prim.nverts do
local m = mats[skin[i] + 1]
if m then
local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
local a = (m[1][1] * x + m[1][2] * y + m[1][3] * z + m[1][4]) * root
local b = (m[2][1] * x + m[2][2] * y + m[2][3] * z + m[2][4]) * root
local c = (m[3][1] * x + m[3][2] * y + m[3][3] * z + m[3][4]) * root
if a < lo1 then lo1 = a end
if b < lo2 then lo2 = b end
if c < lo3 then lo3 = c end
if a > hi1 then hi1 = a end
if b > hi2 then hi2 = b end
if c > hi3 then hi3 = c end
end
end
end
return lo1, lo2, lo3, hi1, hi2, hi3
end
-- (height, floor, radius): how tall the mon is, where its lowest point sits
-- relative to the model's own origin, and how wide it is -- all in game units
-- after the model_root scale.
--
-- Measured on the BIND POSE, which is the one pose in the set that can be
-- trusted for this. It reproduces the verified glTF export exactly on all 151
-- species, and it is immune to the animation quirks a handful of them carry
-- (see idleIsBroken) -- quirks that would otherwise decide how big every OTHER
-- frame of those species is drawn.
--
-- The floor is the interesting number, and it reads cleanly: 119 of the 151
-- sit within 5% of zero, which says the model origin IS where the game stands
-- a Pokemon on its field. Every species that does not is one that hovers.
local function stance(data)
local lo1, lo2, lo3, hi1, hi2, hi3 = poseBox(data, bindMatrices(data.bones))
if lo1 > hi1 then return 0.0, 0.0, 0.0 end
local w, d = hi1 - lo1, hi3 - lo3
return hi2 - lo2, lo2, (w > d and w or d) / 2
end
StadiumBuild.stance = stance
-- Whether this species' standby loop is corrupt as extracted.
--
-- No species trips this today. Exeggutor, Tangela and Magmar used to, when
-- the flags byte was misread and their hermite-keyframe animations were
-- decoded as packed streams, throwing bones hundreds of units off the body.
-- It stays as the guard against the next extraction bug: played, a broken
-- idle looks like a Pokemon coming apart, and the mod would rather show
-- the sprite fallback (see StadiumMon).
--
-- The test is deliberately narrow, because "differs from the bind pose" is NOT
-- brokenness. It is asked only of the STANDBY loop -- the one animation that
-- is supposed to stay where it is, since a faint is meant to end far from the
-- standing pose and an attack is meant to lunge -- and it wants both a large
-- size blow-up and real drift, or an enormous amount of one. Dewgong is what
-- calibrates it: its idle is 2.4x its own bind pose because the BIND is the
-- collapsed one, and it drifts barely at all, so it must not be caught.
local function idleIsBroken(data, idle)
if idle == nil then return false end
local bones = data.bones
local _, lo2, _, _, hi2 = poseBox(data, bindMatrices(bones))
local span = hi2 - lo2
if span <= 0 then return false end
local worstH, worstDrift = 1.0, 0.0
local frame = 0
while frame < idle.frames do
local _, flo2, _, _, fhi2 = poseBox(data,
bindMatrices(bones, animSample(bones, idle, frame)))
local h = (fhi2 - flo2) / span
if h > worstH then worstH = h end
local d1 = (flo2 - lo2) / span
local d2 = (fhi2 - hi2) / span
if d1 < 0 then d1 = -d1 end
if d2 < 0 then d2 = -d2 end
if d1 > worstDrift then worstDrift = d1 end
if d2 > worstDrift then worstDrift = d2 end
frame = frame + 3
end
return (worstH > 2.5 and worstDrift > 1.5)
or worstDrift > 2.0 or worstH > 3.4
end
-- ------- writing
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
-- Toward zero, which is what Python's int() does to a float and NOT what
-- floor() does to a negative one.
--
-- It matters in exactly one place, and it is easy to miss: almost everything
-- reaching the integer writers below has already been rounded, so truncation
-- is a no-op on it. The exception is the generated effects' crossed quads
-- (StadiumFx), whose vertices are raw floats and straddle the origin -- so
-- the ones at negative x, and only those, come out a unit adrift if this
-- floors.
local function trunc(v)
if v >= 0 then return floor(v) end
return -floor(-v)
end
-- 16.16, which holds every bone scale in the set (-31 .. 100) with more
-- precision than anything can see.
local function fixed(v)
return clamp(roundHalfEven(v * 65536), -2147483648, 2147483647)
end
-- IEEE 754 single, little-endian, rounded to nearest with ties to even -- the
-- same rounding Python's struct.pack('<f') does, so the four floats in the
-- header come out bit for bit the same as the packer's.
local function f32(x)
local sign = 0
if x < 0 or (x == 0 and 1 / x < 0) then
sign = 128
x = -x
end
if x ~= x then return char(0, 0, 192, 127 + sign) end -- NaN
if x == math.huge then return char(0, 0, 128, 127 + sign) end
if x == 0 then return char(0, 0, 0, sign) end
local m, e = frexp(x) -- x = m * 2^e, 0.5 <= m < 1
local E = e - 1 + 127
local mant
if E >= 255 then
return char(0, 0, 128, 127 + sign) -- overflow
elseif E <= 0 then
-- subnormal: no exponent left, so the mantissa carries the whole value
mant = roundHalfEven(x / 2 ^ -149)
if mant >= 8388608 then
mant, E = mant - 8388608, 1
else
E = 0
end
else
mant = roundHalfEven((m * 2 - 1) * 8388608)
if mant == 8388608 then -- rounded up into the next
mant, E = 0, E + 1
if E >= 255 then return char(0, 0, 128, 127 + sign) end
end
end
local b4 = sign + floor(E / 2)
local b3 = (E % 2) * 128 + floor(mant / 65536)
local b2 = floor(mant / 256) % 256
local b1 = mant % 256
return char(b1, b2, b3, b4)
end
StadiumBuild.f32 = f32
local Writer = {}
Writer.__index = Writer
local function newWriter()
return setmetatable({ parts = {}, n = 0 }, Writer)
end
function Writer:raw(s)
self.n = self.n + 1
self.parts[self.n] = s
end
function Writer:u8(v)
self:raw(char(v % 256))
end
function Writer:i8(v)
v = clamp(trunc(v), -128, 127)
self:raw(char(v % 256))
end
function Writer:u16(v)
v = v % 65536
self:raw(char(v % 256, floor(v / 256)))
end
function Writer:i16(v)
v = clamp(trunc(v), -32768, 32767) % 65536
self:raw(char(v % 256, floor(v / 256)))
end
function Writer:u32(v)
v = v % 4294967296
self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
floor(v / 16777216) % 256))
end
function Writer:i32(v)
v = clamp(trunc(v), -2147483648, 2147483647) % 4294967296
self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
floor(v / 16777216) % 256))
end
function Writer:f32(v)
self:raw(f32(v))
end
function Writer:bytes()
return concat(self.parts)
end
-- One component of one bone's t/r/s in one animation. `values` is the
-- extractor's own shape: a bare number when the component holds still for the
-- whole animation, or one number a frame when it does not. That fold is where
-- most of the size saving is -- a bone that only rotates costs two bytes for
-- each of its six other components.
local function writeTrackComponent(w, values, kind)
local isArray = type(values) == "table"
w:u8(isArray and 1 or 0)
if kind == "s" then
if isArray then
for i = 1, #values do w:i32(fixed(values[i])) end
else
w:i32(fixed(values))
end
else
if isArray then
for i = 1, #values do w:i16(roundHalfEven(values[i])) end
else
w:i16(roundHalfEven(values))
end
end
end
-- Which animation each fixed battle context slot resolves to: entries 165
-- upward of the species' own battle table, in slot order. An entry naming an
-- animation the species does not have is written as "none" rather than
-- clamped -- the mod would rather fall back than play the wrong clip.
function StadiumBuild.contextTable(rows, nAnims)
local ctx = {}
for i = 1, #StadiumBuild.CONTEXTS do
local row = rows[CTX_BASE + i - 1]
local ai = row and row[1] or nil
ctx[i] = (ai ~= nil and ai < nAnims) and ai or NONE16
end
return ctx
end
-- ------- naming the animations
--
-- build.py's label_animations. The names are not read at runtime -- the mod
-- addresses animations by index through the move and context tables -- but
-- they are in the format, so they have to be produced the same way for the
-- oracle diff to mean anything. They also make a packed file readable in a
-- hex dump, which is worth the byte apiece.
local function labelAnimations(data, rows, nAux)
local anims = data.anims
local n = #anims
local uses, moveUses = {}, {}
local auxOrder, auxCount = {}, {}
for i = 1, n do
uses[i], moveUses[i] = {}, 0
auxOrder[i], auxCount[i] = {}, {}
end
for e = 0, rows.n - 1 do
local ai = rows[e][1]
if ai < n then
if e < N_MOVES then
moveUses[ai + 1] = moveUses[ai + 1] + 1
elseif e >= CTX_BASE and e < CTX_BASE + #StadiumBuild.CONTEXTS then
local list = uses[ai + 1]
list[#list + 1] = StadiumBuild.CONTEXTS[e - CTX_BASE + 1]
end
local ax = rows[e][2]
if ax >= 0 and ax < nAux then
local counts, order = auxCount[ai + 1], auxOrder[ai + 1]
if counts[ax] == nil then
counts[ax] = 0
order[#order + 1] = ax
end
counts[ax] = counts[ax] + 1
end
end
end
for i = 1, n do
-- sorted(set(uses)) -- the alphabetically first context is the fallback
-- name, so the ordering is part of the answer
local seen, ctx = {}, {}
for _, name in ipairs(uses[i]) do
if not seen[name] then
seen[name] = true
ctx[#ctx + 1] = name
end
end
table.sort(ctx)
local name = nil
for _, pref in ipairs(NAME_PREF) do
if seen[pref] then
name = pref
break
end
end
if not name then
if moveUses[i] > 0 then
name = "attack"
elseif ctx[1] then
name = ctx[1]
else
name = "anim" .. (i - 1)
end
end
anims[i].name = name
-- Counter.most_common(1): the highest count, and on a tie the one that
-- was inserted first
local best, bestN = -1, -1
local order, counts = auxOrder[i], auxCount[i]
for _, ax in ipairs(order) do
if counts[ax] > bestN then
best, bestN = ax, counts[ax]
end
end
anims[i].aux = best
end
local seenName = {}
for i = 1, n do
local base = anims[i].name
local k = seenName[base] or 0
seenName[base] = k + 1
if k > 0 then anims[i].name = base .. "_" .. (k + 1) end
end
end
-- ------- the pack
function StadiumBuild.pack(data, species, moveRows, ctx)
local w = newWriter()
local bones, prims = data.bones, data.prims
local textures, anims, aux = data.textures, data.anims, data.auxAnims
local height, floorY, radius = stance(data)
local idleIndex = ctx[1] -- CONTEXTS[1] is "idle"
local idle = (idleIndex ~= NONE16) and anims[idleIndex + 1] or nil
local static = idleIsBroken(data, idle)
w:raw("DSM3")
w:u16(species)
w:u16(#bones)
w:u16(#prims)
w:u16(#textures)
w:u16(#anims)
w:u16(#aux)
w:f32(data.rootScale[1])
-- 1 = hold the bind pose, never play an animation
w:u8(static and 1 or 0)
w:f32(height)
w:f32(floorY)
w:f32(radius)
for m = 1, N_MOVES do
local row = moveRows[m]
w:u16((row and row[1] < #anims) and row[1] or NONE16)
end
for m = 1, N_MOVES do
local row = moveRows[m]
w:i16((row and row[2] >= 0 and row[2] < #aux) and row[2] or -1)
end
for i = 1, #ctx do w:u16(ctx[i]) end
for i = 1, #bones do
local b = bones[i]
w:i16(b.parent)
for k = 1, 3 do w:i16(roundHalfEven(b.t[k])) end
for k = 1, 3 do w:i16(b.r[k]) end
for k = 1, 3 do w:i32(fixed(b.s[k])) end
end
for i = 1, #prims do
local p = prims[i]
w:u16(p.tex)
-- the display list's own cull mode: 1024 is G_CULL_BACK
w:u8((p.cull and p.cull ~= 0) and 1 or 0)
w:u8((p.blend == "add") and 1 or 0)
w:i16(p.texAnim or -1)
-- sorted by the stream's own byte, which is what the reader keys on
local keys = {}
if p.texMap then
for k in pairs(p.texMap) do keys[#keys + 1] = k end
table.sort(keys)
end
w:u8(#keys)
for _, k in ipairs(keys) do
w:u8(k)
w:u16(p.texMap[k])
end
local frames = p.fxFrames
w:u16(frames and #frames or 0)
if frames then
for k = 1, #frames do w:u16(frames[k]) end
end
local pos, uv, nrm, skin = p.pos, p.uv, p.nrm, p.skin
w:u16(p.nverts)
w:u16(p.nidx)
for k = 1, p.nverts do
w:i16(pos[k * 3 - 2])
w:i16(pos[k * 3 - 1])
w:i16(pos[k * 3])
-- 1/512, which puts a texel of the largest texture in the set well
-- inside a step and still reaches the +-32 the wrapped coordinates of
-- some display lists run to
w:i16(roundHalfEven(uv[k * 2 - 1] * 512))
w:i16(roundHalfEven(uv[k * 2] * 512))
w:i8(roundHalfEven(nrm[k * 3 - 2] * 127))
w:i8(roundHalfEven(nrm[k * 3 - 1] * 127))
w:i8(roundHalfEven(nrm[k * 3] * 127))
w:u8(skin[k])
end
for k = 1, p.nidx do w:u16(p.idx[k]) end
end
for i = 1, #textures do
local t = textures[i]
w:u16(t.w)
w:u16(t.h)
w:u32(#t.rgba)
w:raw(t.rgba)
end
local REST = { t = { 0, 0, 0 }, r = { 0, 0, 0 }, s = { 1.0, 1.0, 1.0 } }
for i = 1, #anims do
local a = anims[i]
local name = a.name or ""
if #name > 255 then name = name:sub(1, 255) end
w:u8(#name)
w:raw(name)
w:u16(a.frames)
w:u16(a.loopStart or 0)
w:i16(a.aux or -1)
for bi = 1, #bones do
local tr = a.tracks[bi]
if not tr then
w:u8(0)
else
w:u8(1)
for _, key in ipairs({ "t", "r", "s" }) do
local comps = tr[key]
if comps == nil then
-- a bone the animation leaves at its rest value for this path:
-- written as three constants so the reader never has to branch on
-- a missing path
comps = bones[bi][key] or REST[key]
end
for c = 1, 3 do writeTrackComponent(w, comps[c], key) end
end
end
end
end
for i = 1, #aux do
local a = aux[i]
w:u16(a.frames)
w:u16(a.loopStart or 0)
w:u16(#a.channels)
for _, ch in ipairs(a.channels) do
w:u16(ch.n)
for k = 1, ch.n do w:u16(ch[k]) end
end
end
return w:bytes(), height, floorY, radius
end
-- ------- one species, end to end
-- The same three steps build.py takes: parse the fragment, label the
-- animations off the species' battle table, then hang the generated fire/gas
-- stand-ins on the bones the game's own effect callbacks hang off.
function StadiumBuild.species(rom, fileno)
local blob = rom:model(fileno)
if not blob then return nil, ("file %d is not in the archive"):format(fileno) end
local data, err = StadiumFragment.extract(blob, ("%d.bin"):format(fileno))
if not data then return nil, err end
local species = data.species
local rows = rom:battleRows(species)
labelAnimations(data, rows, #data.auxAnims)
StadiumFx.attach(data, species)
local moveRows = {}
for m = 1, N_MOVES do moveRows[m] = rows[m - 1] end
local ctx = StadiumBuild.contextTable(rows, #data.anims)
local bytes, height, floorY, radius =
StadiumBuild.pack(data, species, moveRows, ctx)
-- ------- 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 }
end
-- ------- the stepped job
--
-- `write(species, bytes)` is called for each finished pack and must answer
-- truthy; anything else stops the job with an error. Returning a job rather
-- than taking a callback for progress keeps the caller in charge of when work
-- happens, which is what lets a loading screen stay responsive.
function StadiumBuild.job(rom, write, count)
local total = count or StadiumRom.N_POKEMON
local n = rom:modelCount()
if total > n then total = n end
local job = { total = total, done = 0, bytes = 0, failed = {}, species = nil }
function job:step()
if self.done >= self.total then return false end
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, 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
self.lastError = ok and err or res
end
self.done = self.done + 1
return self.done < self.total
end
function job:progress()
if self.total <= 0 then return 1 end
return self.done / self.total
end
return job
end
return StadiumBuild
File diff suppressed because it is too large Load Diff
+436
View File
@@ -0,0 +1,436 @@
-- STADIUM battles: the generated fire and gas stand-ins.
--
-- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement
-- build.py sizes them against.
--
-- IMPORTANT: nothing here is extracted game data. The real tail flame, mane
-- fire and gas are drawn by procedural callbacks that live in a different
-- fragment -- geo command 0x08 records an attachment point and
-- func_80014A60 calls node->unk_10, and the model file supplies only two
-- empty display lists plus zeroed scratch buffers for it to fill. Those
-- callbacks have not been ported, so the models genuinely contain no flame
-- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth
-- and skin.
--
-- What follows is an ORIGINAL, procedurally generated replacement -- looping
-- flipbook noise on a pair of crossed quads, anchored to the exact bone the
-- callback hangs off so it sits where the real effect would and follows the
-- animation. Seeds derive from the species number, so a given Pokemon always
-- generates the same flame.
--
-- Which species get one is the game's own grouping: every species sharing a
-- callback shares an effect.
--
-- 0x810000D8 Charmander, Charmeleon, Charizard, Magmar, Moltres tail flame
-- 0x81000108 Ponyta, Rapidash, Moltres's wings small flame
-- 0x810000E0 Gastly (only) gas cloud
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumFx = {}
local floor = math.floor
local sqrt = math.sqrt
local sin, cos = math.sin, math.cos
local char = string.char
local concat = table.concat
local pi = math.pi
local FIRE_TAIL = 0x810000D8
local FIRE_SMALL = 0x81000108
local AURA = 0x810000E0
-- Desired size as a fraction of the model's world-space height: length, width.
StadiumFx.SIZES = {
fire_tail = { 0.40, 0.22 },
fire_small = { 0.075, 0.042 },
gas = { 1.05, 1.05 },
}
-- ------- 32-bit exclusive-or, in arithmetic
--
-- The generator below is an xorshift, so it needs a real 32-bit xor and a
-- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which
-- works in SIGNED 32-bit and would need converting back on every step -- see
-- the same note in StadiumFragment.
local function bxor32(a, b)
local r, p = 0, 1
for _ = 1, 32 do
local x, y = a % 2, b % 2
if x ~= y then r = r + p end
a, b, p = floor(a / 2), floor(b / 2), p * 2
end
return r
end
-- ------- deterministic noise
local Rng = {}
Rng.__index = Rng
local function newRng(seed)
local s = seed % 0x100000000
if s == 0 then s = 0x9E3779B9 end
return setmetatable({ s = s }, Rng)
end
function Rng:next()
local x = self.s
x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13)
x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17
x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5)
self.s = x % 0x100000000
return self.s
end
function Rng:unit()
return self:next() / 0x100000000
end
-- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and
-- therefore the texture -- is reproducible.
local function lattice(rng, w, h)
local g = {}
for y = 1, h do
local row = {}
for x = 1, w do row[x] = rng:unit() end
g[y] = row
end
return g
end
local function smooth(t)
return t * t * (3 - 2 * t)
end
-- Bilinear value noise on a torus, so the field tiles in both axes.
local function sample(grid, x, y)
local h = #grid
local w = #grid[1]
local fx0, fy0 = floor(x), floor(y)
local x0, y0 = fx0 % w, fy0 % h
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
local fx, fy = smooth(x - fx0), smooth(y - fy0)
local r0, r1 = grid[y0 + 1], grid[y1 + 1]
local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx
local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx
return a + (b - a) * fy
end
-- Sum octaves of tileable noise.
local function fbm(grids, x, y, scale)
local total, amp, norm = 0.0, 1.0, 0.0
for i = 1, #grids do
local f = scale * 2 ^ (i - 1)
total = total + sample(grids[i], x * f, y * f) * amp
norm = norm + amp
amp = amp * 0.5
end
return total / norm
end
-- Intensity -> RGBA, through a piecewise ramp.
local function ramp(stops, t)
if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end
for i = 1, #stops - 1 do
local a, b = stops[i], stops[i + 1]
if t <= b[1] then
local k = 0.0
if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end
return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k),
floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k)
end
end
local last = stops[#stops]
return last[2], last[3], last[4], last[5]
end
local FIRE_RAMP = {
{ 0.00, 0, 0, 0, 0 },
{ 0.30, 120, 24, 8, 90 },
{ 0.52, 226, 78, 16, 205 },
{ 0.74, 252, 176, 44, 245 },
{ 1.00, 255, 246, 214, 255 },
}
local GAS_RAMP = {
{ 0.00, 0, 0, 0, 0 },
{ 0.34, 52, 26, 78, 70 },
{ 0.60, 96, 52, 140, 140 },
{ 0.82, 148, 96, 196, 190 },
{ 1.00, 208, 176, 236, 215 },
}
local TRANSPARENT = char(0, 0, 0, 0)
-- An upward-advected noise plume. Scrolling by an exact multiple of the
-- lattice over the frame count is what makes the loop seamless.
local function fireFrames(seed, w, h, frames, wisp)
wisp = wisp or 1.0
local rng = newRng(seed)
local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
lattice(rng, 32, 32) }
local out = {}
for f = 0, frames - 1 do
local t = f / frames
local buf = {}
for i = 1, w * h do buf[i] = TRANSPARENT end
for y = 0, h - 1 do
local v = y / (h - 1) -- 0 at the base, 1 at the tip
-- plume envelope: wide and hot at the base, pinched at the tip
local taper = 1.0 - v
if taper < 0.0 then taper = 0.0 end
taper = taper ^ 0.42
for x = 0, w - 1 do
local u = (x / (w - 1)) * 2 - 1 -- -1 .. 1 across the flame
local denom = taper * 0.95
if denom < 0.10 then denom = 0.10 end
local radial = 1.0 - (u < 0 and -u or u) / denom
if radial > 0 then
radial = radial ^ 0.7
local n = fbm(grids, x / w, (y / h) - t, 3.0)
local lick = 0.55 + 0.75 * (n - 0.5) * wisp
local inten = radial * (0.55 + 0.8 * taper) * lick
inten = inten - 0.16 * v -- cool towards the tip
if inten > 0.02 then
local r, g, b, a = ramp(FIRE_RAMP, inten)
-- +Y in texture space is up
buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a)
end
end
end
end
out[f + 1] = concat(buf)
end
return w, h, out
end
-- Slow swirling haze that fades out towards the rim.
local function gasFrames(seed, w, h, frames)
local rng = newRng(seed)
local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
lattice(rng, 32, 32) }
local out = {}
for f = 0, frames - 1 do
local t = f / frames
local buf = {}
for i = 1, w * h do buf[i] = TRANSPARENT end
local ang = t * 2 * pi
local ca, sa = cos(ang), sin(ang)
for y = 0, h - 1 do
for x = 0, w - 1 do
local dx = (x / (w - 1)) * 2 - 1
local dy = (y / (h - 1)) * 2 - 1
local d = sqrt(dx * dx + dy * dy)
if d < 1.0 then
local falloff = (1.0 - d) ^ 0.85
-- rotate the sample point so the haze churns without popping
local sx = dx * ca - dy * sa
local sy = dx * sa + dy * ca
local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
local inten = falloff * (0.78 + 1.30 * (n - 0.44))
if inten > 0.03 then
local r, g, b, a = ramp(GAS_RAMP, inten)
buf[y * w + x + 1] = char(r, g, b, a)
end
end
end
end
out[f + 1] = concat(buf)
end
return w, h, out
end
-- Two quads at right angles, so the effect reads from any angle. `axis` picks
-- which bone-local direction the quad grows along: bone-local +X runs down the
-- limb, so a flame laid out along X comes out lying sideways, and 'y' is that
-- same quad turned a quarter left about Z, which stands it up. `centred`
-- straddles the origin instead of growing from it.
local function crossedQuads(bone, length, width, axis, centred)
local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {}
local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } }
local nv, ni = 0, 0
for q = 0, 1 do
local base = nv
for k = 1, 4 do
local s, t = ST[k][1], ST[k][2]
local a = (s - 0.5) * width
local b = centred and (t - 0.5) * length or t * length
local px, py, pz
if axis == "x" then
if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end
else -- (x, y) -> (-y, x)
if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end
end
pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz
uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t
if q == 0 then
nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0
else
nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0
end
skin[nv + 1] = bone
nv = nv + 1
end
idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2
idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3
ni = ni + 6
end
return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv,
idx = idx, nidx = ni }
end
-- ------- the bind pose these are sized against
--
-- build.py's bind_extent, kept in its own 4x4 column-major convention rather
-- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the
-- same skeleton -- but the effect sizes come out of THIS one's per-bone scale
-- measurement, and rewriting it into the other convention is exactly the kind
-- of change that moves a byte without anyone noticing.
local function trs(t, r, s)
local function S(v) return sin(v / 32768 * pi) end
local function C(v) return cos(v / 32768 * pi) end
local sx, cx = S(r[1]), C(r[1])
local sy, cy = S(r[2]), C(r[2])
local sz, cz = S(r[3]), C(r[3])
return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0,
(sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2],
sx * cy * s[2], 0,
(cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3],
cx * cy * s[3], 0,
t[1], t[2], t[3], 1 }
end
local function mul(a, b)
local r = {}
for c = 0, 3 do
for i = 1, 4 do
r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2]
+ a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4]
end
end
return r
end
-- (height of the bind pose, per-bone local scale). Height rather than the
-- largest dimension: sizing off the max would scale Moltres's flames to its
-- wingspan.
function StadiumFx.bindExtent(data)
local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale)
local acc, uns, mats = {}, {}, {}
for i = 1, #data.bones do
local b = data.bones[i]
local p = b.parent
local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
local pu = (p >= 0) and uns[p + 1] or root
local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] },
b.r, { 1, 1, 1 }))
local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] }
local m = {}
for k = 1, 16 do m[k] = u[k] end
for k = 1, 4 do
m[k] = m[k] * a[1]
m[4 + k] = m[4 + k] * a[2]
m[8 + k] = m[8 + k] * a[3]
end
acc[i], uns[i], mats[i] = a, u, m
end
local lo = { 1e9, 1e9, 1e9 }
local hi = { -1e9, -1e9, -1e9 }
for _, prim in ipairs(data.prims) do
local pos, skin = prim.pos, prim.skin
for i = 1, prim.nverts do
local m = mats[skin[i] + 1]
if m then
local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
local wx = m[1] * x + m[5] * y + m[9] * z + m[13]
local wy = m[2] * x + m[6] * y + m[10] * z + m[14]
local wz = m[3] * x + m[7] * y + m[11] * z + m[15]
if wx < lo[1] then lo[1] = wx end
if wy < lo[2] then lo[2] = wy end
if wz < lo[3] then lo[3] = wz end
if wx > hi[1] then hi[1] = wx end
if wy > hi[2] then hi[2] = wy end
if wz > hi[3] then hi[3] = wz end
end
end
end
local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0
-- how much each bone scales its own local space, so an effect can divide it
-- back out and come out the size it asked for wherever it hangs
local scales = {}
for i = 1, #mats do
local m = mats[i]
scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3])
end
return extent, scales
end
-- ------- what a species gets
-- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list.
function StadiumFx.buildFor(species, fx, extent, boneScale)
local out = {}
for _, node in ipairs(fx) do
local cb, bone = node.callback, node.bone
if bone >= 0 and bone < #boneScale then
local k = boneScale[bone + 1]
if k == 0 then k = 1.0 end
if cb == FIRE_TAIL then
local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2]
local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8)
out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
geo = crossedQuads(bone, extent * fl / k,
extent * fw / k, "y", false) }
elseif cb == FIRE_SMALL then
local fl, fw = StadiumFx.SIZES.fire_small[1],
StadiumFx.SIZES.fire_small[2]
local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25)
out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
geo = crossedQuads(bone, extent * fl / k,
extent * fw / k, "y", false) }
elseif cb == AURA and species == 92 then -- Gastly only
local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2]
local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10)
out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr,
geo = crossedQuads(bone, extent * fl / k,
extent * fw / k, "y", true) }
end
end
end
return out
end
-- Append the generated prims and their flipbook textures to a model, exactly
-- as build.py's attach_effects does. Returns how many were made.
function StadiumFx.attach(data, species)
if not (data.fx and #data.fx > 0) then return 0 end
local extent, boneScale = StadiumFx.bindExtent(data)
local made = StadiumFx.buildFor(species, data.fx, extent, boneScale)
for _, e in ipairs(made) do
local first = #data.textures -- 0-based, as the file
for i = 1, #e.frames do
data.textures[first + i] = { index = -1, w = e.w, h = e.h,
generated = true, rgba = e.frames[i] }
end
local g = e.geo
local fxFrames = {}
for i = 1, #e.frames do fxFrames[i] = first + i - 1 end
data.prims[#data.prims + 1] = {
tex = first, cull = 0, texAnim = -1, texMap = nil,
generated = true, effect = e.kind,
blend = (e.kind == "fire") and "add" or "alpha",
fxFrames = fxFrames,
pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts,
idx = g.idx, nidx = g.nidx,
}
end
return #made
end
return StadiumFx
+414
View File
@@ -0,0 +1,414 @@
-- STADIUM battles: finding the ROM, and building the models out of it once.
--
-- The mod does not ship the Pokemon Stadium models and cannot: they are that
-- game's data. What it ships is the READER -- StadiumRom, StadiumFragment,
-- StadiumFx and StadiumBuild -- and the player supplies the cartridge, which
-- is exactly the arrangement this engine already has for the Game Boy ROM it
-- is a recompilation of (src/import/RomImporter.lua).
--
-- So: supply a Pokemon Stadium (US) 1.0 ROM -- the OPTIONS row opens a file
-- picker for one, or drop it in `baseroms/` -- and the first time the
-- game runs with the mod on, the models are built. Once, on a loading screen,
-- in about ten seconds. After that the packs sit in the save directory and
-- the mod reads them like any other asset.
--
-- ------- where "baseroms/" is
--
-- One relative path, and it deliberately covers two different places at once,
-- because PhysFS searches the save directory AND the game folder under the
-- same names:
--
-- * a folder install, or a checkout -- `baseroms/` next to main.lua
-- * a packaged or fused build, where the game folder is inside an archive
-- and cannot be written to -- `baseroms/` in the save directory, whose
-- absolute path this reports on screen so it can be found
--
-- The file goes STRAIGHT IN THERE, with no revision subfolder under it. The
-- decompilation's own `make init` uses `baseroms/us/`, and the offline
-- pipeline under model_extract/ still reads from there because it shares that
-- tree -- but the instruction given to a player is "drop the file in this
-- folder", and one folder is the whole of it.
--
-- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the
-- byte order on load.
--
-- ------- what "installed" means
--
-- A marker file next to the packs, holding the format magic, how many species
-- were written and the md5 of the ROM they came from. All three matter. The
-- magic catches a format change (the packs are rebuilt rather than read as
-- garbage), the count catches a build that was interrupted half way, and the
-- md5 catches the player swapping the ROM for a different revision.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumPack = V.require("StadiumPack")
local StadiumInstall = {}
-- Where a ROM is looked for, and where the built packs are kept.
StadiumInstall.ROM_DIR = "baseroms"
StadiumInstall.DIR = StadiumPack.CACHE_DIR
StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info"
-- Bumped whenever the .dsm format changes, so an old cache is rebuilt rather
-- than misread. Must track StadiumPack's magic.
StadiumInstall.FORMAT = "DSM3"
-- Bumped when the packs' CONTENT changes without the byte layout moving, so
-- a cache built by an older extractor is rebuilt rather than trusted. Rev 2
-- 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.
--
-- 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
-- Named ROM files, then any ROM at all sitting in the folder.
--
-- Flat in `baseroms/`, with no revision subfolder: the offline pipeline under
-- model_extract/ keeps the decompilation's own `baseroms/us/` convention
-- because it shares that tree, but what is being asked of a PLAYER here is
-- "drop the file in this folder", and one folder is the whole of that
-- instruction. A path they have to build out of two parts is a path half of
-- them will get wrong, and the failure is silent -- the rungs are simply not
-- on the row.
local NAMED = {
StadiumInstall.ROM_DIR .. "/baserom.z64",
StadiumInstall.ROM_DIR .. "/baserom.n64",
StadiumInstall.ROM_DIR .. "/baserom.v64",
}
local function fs()
return love and love.filesystem
end
local function isFile(path)
local f = fs()
if not (f and f.getInfo) then return false end
local ok, info = pcall(f.getInfo, path, "file")
return (ok and info) and true or false
end
-- The ROM's path on the PhysFS read path, or nil.
function StadiumInstall.romPath()
local f = fs()
if not f then return nil end
for _, path in ipairs(NAMED) do
if isFile(path) then return path end
end
local ok, items = pcall(f.getDirectoryItems, StadiumInstall.ROM_DIR)
if ok and items then
table.sort(items)
for _, name in ipairs(items) do
if name:lower():match("%.[nvz]64$") then
local path = StadiumInstall.ROM_DIR .. "/" .. name
if isFile(path) then return path end
end
end
end
return nil
end
function StadiumInstall.romPresent()
return StadiumInstall.romPath() ~= nil
end
-- Where to tell the player to put it. The save directory is the answer that
-- is always writable, and it is the one a packaged build needs.
function StadiumInstall.romHint()
local f = fs()
local base = (f and f.getSaveDirectory and select(2, pcall(f.getSaveDirectory)))
if type(base) ~= "string" then base = "the game folder" end
return base .. "/" .. StadiumInstall.ROM_DIR
end
-- The same thing with a FILENAME on the end, which is what a player actually
-- needs: a folder alone leaves them guessing what to call the file, and the
-- guess is not obviously "baserom.z64".
--
-- Taken from the head of NAMED rather than retyped, so the name shown is by
-- construction the first name looked for. It is not the ONLY one that works
-- -- `.n64` and `.v64` are accepted, and so is any other name carrying one
-- of those extensions -- but an instruction that names one file is one a
-- player can follow, and an instruction that lists every possibility is one
-- they have to interpret.
function StadiumInstall.romHintFile()
return StadiumInstall.romHint() .. "/" .. (NAMED[1]:match("[^/]+$") or "")
end
-- ------- the marker
local function readMarker()
local f = fs()
if not (f and isFile(StadiumInstall.MARKER)) then return nil end
local ok, text = pcall(f.read, StadiumInstall.MARKER)
if not (ok and type(text) == "string") then return nil end
local format, count, md5, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)")
if not format then return nil end
return { format = format, count = tonumber(count), md5 = md5,
rev = tonumber(rev) }
end
-- Whether a complete, current set of packs is on disk.
local readyCache = nil
function StadiumInstall.ready()
if readyCache ~= nil then return readyCache end
local m = readMarker()
readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT
and m.count == StadiumInstall.COUNT
and m.rev == StadiumInstall.REV) and true or false
return readyCache
end
-- Whether a complete set came WITH the mod folder -- a developer checkout
-- that has run tools/stadium_pack.py. Never true of a released build, which
-- carries no models at all.
--
-- Sampled at both ends of the dex rather than counted. The question being
-- asked is "did somebody run the packer here", not "is every one of the 151
-- present"; a genuinely half-written folder is a case for the marker file,
-- which is what catches an interrupted RUNTIME build.
local function shipped()
local mod = V.mod
if not (mod and mod.read) then return false end
for _, dex in ipairs({ 1, 151 }) do
local ok, bytes = pcall(mod.read, mod,
("%s/%03d.dsm"):format(StadiumPack.DIR, dex))
if not (ok and type(bytes) == "string" and #bytes > 4) then return false end
end
return true
end
-- 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: a ROM to build from, and no CURRENT set.
--
-- 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 not StadiumInstall.romPresent() then return false end
return not StadiumInstall.ready()
end
function StadiumInstall.forget()
readyCache = nil
end
-- ------- building
local job = nil
local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }
StadiumInstall.status = status
-- 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
-- Open the ROM found in `baseroms/` and start a stepped build. Returns false
-- plus a reason when there is nothing to build from.
function StadiumInstall.begin()
local f = fs()
if not f then return false, "no filesystem" end
local path = StadiumInstall.romPath()
if not path then return false, "no ROM in " .. StadiumInstall.ROM_DIR end
local okRead, bytes = pcall(f.read, path)
if not (okRead and type(bytes) == "string") then
return false, "could not read " .. path
end
return StadiumInstall.beginFrom(bytes, path)
end
-- The same, from bytes somebody else has already got hold of -- which is the
-- IMPORTED path (StadiumRomPick), where the file is at an absolute location
-- love.filesystem cannot see and was read with io.open.
--
-- The two entry points share everything from here down on purpose: an
-- imported cartridge and a dropped one produce the same 151 files, the same
-- marker and the same md5, so there is exactly one build in this mod and no
-- second one to keep in step.
--
-- `label` is only ever used to say WHICH file a complaint is about.
function StadiumInstall.beginFrom(bytes, label)
local f = fs()
if not f then return false, "no filesystem" end
if type(bytes) ~= "string" or #bytes == 0 then return false, "empty file" end
local StadiumRom = V.require("StadiumRom")
local StadiumBuild = V.require("StadiumBuild")
local rom, err = StadiumRom.open(bytes)
if not rom then return false, tostring(err) end
status.wrongVersion = false
if not rom:isExpectedUS() then
-- Built anyway rather than refused: a dump can differ from the reference
-- for reasons that do not move a single model offset (a byte-order
-- variant already normalised on load, a trimmed overdump). But every
-- offset in this reader was measured against US 1.0 and nothing else is
-- promised, so it is said loudly, with the md5 that IS expected so the
-- player can check their own file against it.
status.wrongVersion = true
V.mod.log:warn("stadium: %s is md5 %s -- the model offsets are keyed to "
.. "Pokemon Stadium (US) 1.0, which is md5 %s. Building "
.. "anyway, but the models may be wrong or fail to build.",
tostring(label or "the ROM"), tostring(rom:md5()),
tostring(StadiumRom.US_MD5))
end
-- ------- refuse a ROM with no models in it, BEFORE anything is written
--
-- A file picker invites the wrong file -- most obviously the Game Boy
-- cartridge the player already imported once -- and the reader's answer to
-- one is a model count of zero. That has to be caught HERE rather than
-- allowed to become an empty build, because an empty build is
-- indistinguishable from a finished one further down: `job.total` is
-- clamped to the count, `step` completes on the first call with nothing
-- attempted and therefore nothing FAILED, and the marker gets written
-- saying `DSM3 0`.
--
-- On a fresh machine that is merely a lie on the loading screen -- READY,
-- with no models. On one that already HAD them it is worse: the marker is
-- the only thing that makes 151 files on disk count as installed, so
-- overwriting it with a zero uninstalls a good set and the STADIUM rungs
-- vanish off the row. Nothing below this line runs for a file that cannot
-- possibly produce a build.
local models = rom:modelCount()
if not (models and models >= StadiumInstall.COUNT) then
return false, "needs Pokemon Stadium US 1.0"
end
pcall(f.createDirectory, StadiumInstall.DIR)
job = StadiumBuild.job(rom, writePack, StadiumInstall.COUNT)
job.md5 = rom:md5()
status.state = "building"
status.done = 0
status.total = job.total
status.error = nil
return true
end
-- One species. Returns true while there is more to do.
function StadiumInstall.step()
if not job then return false end
local more = job:step()
status.done = job.done
status.species = job.species
if job.error then
status.state = "failed"
status.error = job.error
job = nil
return false
end
if not more then
local f = fs()
-- `job.total > 0` as well as "nothing failed", because a job with nothing
-- IN it satisfies the second on its own -- and the marker this writes is
-- what makes a set count as installed, so it must never be written for a
-- build that did not happen. beginFrom refuses such a ROM outright; this
-- is the same rule stated where the consequence is.
local wrote = #job.failed == 0 and job.total > 0
if wrote and f then
pcall(f.write, StadiumInstall.MARKER,
("%s %d %s %d\n"):format(StadiumInstall.FORMAT, job.total,
tostring(job.md5 or ""),
StadiumInstall.REV))
readyCache = nil
StadiumPack.forget()
end
if not wrote then
status.state = "failed"
-- EVERY species failing is not a bad build, it is the wrong file: the
-- offsets the reader walks are Pokemon Stadium's, so a different game
-- -- or the Game Boy cartridge the player already imported once, which
-- is the mistake a file picker invites -- misses on all 151 rather than
-- on a few. Worth telling apart, because "0 of 151 models were built"
-- reads as a broken mod and this reads as a wrong click.
if #job.failed >= job.total then
status.error = "needs Pokemon Stadium US 1.0"
else
status.error = ("%d of %d models could not be built")
:format(#job.failed, job.total)
end
else
status.state = "done"
end
job = nil
return false
end
return true
end
function StadiumInstall.cancel()
job = nil
status.state = "idle"
end
return StadiumInstall
+548
View File
@@ -0,0 +1,548 @@
-- STADIUM battles: one Pokemon, standing on its tile.
--
-- The side's live state -- which species is out, the rig posing it, which
-- animation the fight has asked for and how far through it is, and the
-- matrix that puts it on its cell at the right size facing the right way.
-- Stadium owns the pair of these; StadiumRig owns the arithmetic.
--
-- ------- how big a Pokemon is
--
-- The one genuinely invented number in this mode, and it is worth saying
-- why it is invented rather than measured.
--
-- The flat 2D-3D mode has an exact answer: a full-size 56-pixel pic covers
-- one 16-pixel overworld square, so a canvas pixel is a fixed number of
-- world pixels and every species comes out at whatever its own artwork's
-- size implies (see BattleBillboard.FULL_W). The camera is then SOLVED to
-- make one square that big on screen (BattleCam).
--
-- The Stadium models have no such anchor. Their units are the N64's, they
-- run from Caterpie at 9 units to Gyarados at 147 -- a sixteenfold spread,
-- where the Gen 1 pics span barely one and a half -- and the game they come
-- from framed each one with its own camera, which a fight staged on the
-- overworld cannot do because the two mons share a shot.
--
-- Taken literally, that spread puts Caterpie at a couple of pixels on a
-- 144-pixel screen while Gyarados leaves the frame. So the range is
-- COMPRESSED rather than either honoured or discarded: a species is drawn
-- at REF_HEIGHT world pixels scaled by its own height over the set's
-- median, raised to SQUASH. At 1 that would be the raw sixteenfold spread;
-- at 0 every Pokemon would be the same size; at 0.55 the order and the
-- feel of the differences survive -- Onix and Gyarados tower, Diglett and
-- Caterpie are small enough to have to look for -- inside a range a shared
-- frame can hold.
--
-- ------- and where its feet are
--
-- The pack measures each model's lowest point against its own origin
-- (tools/stadium_pack.py's `stance`), and the answer splits the set in
-- three. 119 species sit within 5% of zero: the origin IS the floor, and
-- the game stood them on its field with it. A handful sit ABOVE it --
-- Zubat, Magnemite, Geodude -- which is a hover the model is authored with.
-- The rest hang BELOW it -- Tentacruel, Gastly, Haunter, Weezing, Zapdos --
-- which is a model centred on its origin rather than standing on it.
--
-- So a model is stood on its own lowest point, and then given back as much
-- of its authored hover as the shot can hold -- HOVER_CAP of its own height,
-- no more. The middle group is unaffected either way, which is the check
-- that the rule is reading the data rather than correcting it.
--
-- The cap is not tidiness. Stadium framed one Pokemon per camera and could
-- afford to hang Zubat three body-heights off the floor; this shot has the
-- foe's feet on GB row 56 of 144, so the same hover puts Zubat off the top
-- of the frame entirely -- which is exactly what it did before the cap. The
-- flat 2D-3D mode has the same constraint and answers it by bottom-aligning
-- every pic, hovering species included; this keeps the hover but spends
-- only the room there is.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local StadiumPack = V.require("StadiumPack")
local StadiumRig = V.require("StadiumRig")
local StadiumMon = {}
StadiumMon.__index = StadiumMon
-- How tall a median Pokemon stands, in world pixels.
--
-- Not picked by eye: it is what the FLAT mode already puts on those cells.
-- A full-size Gen 1 pic is 56 pixels for the foe and 64 for the player's
-- own, drawn with its feet on GB rows 56 and 96 of a 144-row frame -- so a
-- full-size mon covers 39% of the frame at the far cell and 44% at the near
-- one. Against the lens BattleCam solves (about 38 world pixels of frame at
-- the far cell, 30 at the near one, because the near one is closer) both of
-- those work out at roughly fourteen world pixels.
--
-- So this is the number that makes a median Stadium model exactly as big as
-- the artwork it replaces, which is what keeps the composition the camera
-- was solved for.
StadiumMon.REF_HEIGHT = 14
-- The set's own median bind height, in game units (tools/stadium_pack.py
-- --report prints it). Only ever a reference point for the ratio above, so
-- a re-extraction that moved it slightly changes nothing but the middle of
-- the ladder.
StadiumMon.MEDIAN = 52.25
-- How much of the raw size spread survives. See the header.
StadiumMon.SQUASH = 0.5
-- And hard stops either end, because a compression is not a guarantee. The
-- ceiling is what keeps Onix and Gyarados inside a frame whose top edge is
-- only 56 GB rows above the foe's own feet: past about this they stop being
-- imposing and start being cropped.
StadiumMon.MIN_HEIGHT = 5
StadiumMon.MAX_HEIGHT = 18
-- How much of an authored hover survives, as a fraction of the Pokemon's
-- own height. See the header: Stadium could hang a flier three body-heights
-- up because it framed one Pokemon at a time.
StadiumMon.HOVER_CAP = 0.5
-- The animation clock. Every animation in the set is authored at 30 fps
-- (model_extract/README.md), and the eyes run on their own counter at the
-- same rate.
StadiumMon.FPS = StadiumPack.FPS
-- ------- coming out of the ball
--
-- The engine grows its flat pic in the Game Boy's own three steps -- 0, then
-- 3/7, then 5/7, then full -- across the twelve frames after the ball opens
-- (BattleState.growInScale). Two things about that do not carry to a model.
--
-- It is three steps, which on a 56-pixel sprite is a chunky pop and on a
-- smooth 3D model is just a pop. And it starts AFTER the ball: measured, the
-- poof animation runs for 27 frames and `startGrowIn` fires on the frame
-- after it ends, so the Pokemon does not begin to exist until the ball has
-- finished opening -- which reads as the ball opening and then a Pokemon
-- being switched on beside it.
--
-- So the model runs its own ramp, started when the POOF begins rather than
-- when it ends, and continuous rather than stepped: it grows out of nothing
-- while the ball is opening and reaches full size as the engine's own grow
-- finishes. GROW_TIME is measured off that -- 27 frames of poof plus the
-- engine's 12 of grow is 39, which is this.
StadiumMon.GROW_TIME = 0.65
-- How far an animation may carry the Pokemon off its tile, in the Pokemon's
-- own body-heights, before the excess is taken back out (StadiumRig.anchor).
--
-- Measured against the frame rather than chosen by eye. A mon is drawn
-- REF_HEIGHT world pixels tall and the GB frame holds about 38 world pixels
-- at the far cell, with the foe's feet on row 56 of 144 -- so there is
-- roughly one body-height of room above it and about one and a half either
-- side. Three quarters of a height keeps every part of a travelling Pokemon
-- inside that with a margin, and leaves the 83 species that never reach it
-- untouched.
StadiumMon.TRAVEL = 0.75
-- ------- the animation the fight is asking for
--
-- Each entry says which context slot to look up, whether it loops, and
-- what it falls back to when the species has no animation in that slot.
-- ------- there is no hit reaction, and there never was
--
-- This used to carry `hit` and `flinch` states, played when damage landed,
-- resolving through context slots 166 and 178. Both were wrong, and the data
-- says so plainly once the move table is read alongside them:
--
-- Bulbasaur's slot 166 is a 95-frame animation that 66 of its moves play.
-- Pidgey's is 138 frames -- four and a half seconds -- and 111 of its moves
-- play it. Slot 178, and 173, 179, 180 and 181, all point at the same one.
--
-- A four-and-a-half-second animation that most of the move table uses is the
-- species' DEFAULT ATTACK, not a flinch, which is why being hit looked like
-- swinging: it literally was the swing.
--
-- Nor is the reaction hiding elsewhere. Exactly one animation per species is
-- claimed by no slot and no move, and it is the same length as the idle for
-- essentially every one of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a
-- second standby loop, not a recoil. The set has no damage reaction in it.
--
-- So damage plays nothing, and the Pokemon carries on with what it was doing.
-- That is not a gap: the engine flashes the screen, blinks the pic and drains
-- the bar, which is how Gen 1 says "that hurt" and is already in the frame.
local STATES = {
idle = { slot = "idle", loop = true },
entrance = { slot = "entrance", loop = false, next = "idle" },
faint = { slot = "faint", loop = false, hold = true },
-- A move names its own animation out of the move table. `attack_default`
-- is the fallback for one the table has nothing for -- which is what slot
-- 166 actually is, so the generic swing is now a real swing rather than
-- the standby loop it used to resolve to.
attack = { slot = "attack_default", loop = false, next = "idle" },
}
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",
anim = nil, -- index into model.anims
time = 0, -- seconds into it
loop = true,
hold = false,
aux = nil, -- the texture animation running alongside
visible = false,
scale = 1, -- the send-out grow, 1 the rest of the time
}, StadiumMon)
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
--
-- Returns true when the model is ready to draw. A species with no pack, one
-- whose meshes would not build, or one whose animation data is corrupt at
-- source answers false -- and Stadium then leaves that side to the flat
-- card, which is a per-POKEMON decline rather than a per-battle one: a fight
-- can perfectly well have a model on one side and a pic on the other.
--
-- ------- staticPose: the corrupt-idle escape hatch
--
-- StadiumBuild.idleIsBroken measures whether a species' standby loop throws
-- bones off the body, and the pack carries the verdict as `staticPose`. A
-- species so marked DECLINES here -- the Game Boy's own battle sprite
-- stands on the tile instead, drawn by the same 2D-3D path every species
-- uses when its model is unavailable -- because a bind pose held for a
-- whole fight reads as broken, not as "this one does not animate".
--
-- No species is marked today. Exeggutor, Tangela and Magmar used to be:
-- their animations are hermite keyframes (flags & 8), the extractor misread
-- the flags byte and decoded them as packed streams, and the exploding
-- result tripped the detector (Pidgeot and Dodrio were garbled by the same
-- bug, just not hard enough to trip it). The detector stays, keyed on the
-- 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.
--
-- `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, 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
self.model, self.rig = model, rig
-- a new Pokemon on the field opens on its standby loop; whoever sent it
-- out asks for the entrance a moment later
self.state, self.anim, self.time = nil, nil, 0
self:play("idle")
return true
end
-- ------- the state machine
-- Which animation a context slot resolves to for this species, or nil.
function StadiumMon:slotAnim(name)
local model = self.model
local slot = model and StadiumPack.SLOT[name]
if not slot then return nil end
local index = model.ctx[slot]
if not index or index == StadiumPack.NONE then return nil end
return index + 1
end
-- Start a state. `animIndex` overrides the state's own slot lookup, which
-- is what an attack uses.
function StadiumMon:play(state, animIndex, auxIndex)
local model = self.model
if not model then return false end
local def = STATES[state] or STATES.idle
local index = animIndex
if not index and def.slot then index = self:slotAnim(def.slot) end
if not index and def.fallback then index = self:slotAnim(def.fallback) end
if not index then
-- the species has nothing for this; the standby loop is always there
if state == "idle" then index = 1 else return self:play("idle") end
end
local anim = model.anims[index]
if not anim then return false end
self.state, self.anim, self.time = state, index, 0
self.done = false
-- (a species whose animations are corrupt at source never gets this far:
-- setSpecies declines it outright and its flat pic stands instead)
self.loop = def.loop and true or false
self.hold = def.hold and true or false
-- The eyes that go with it. Every skeletal animation carries the texture
-- animation the battle table most often set alongside it (the pack's own
-- `aux`), and a move may name a different one -- a hit that leaves the
-- Pokemon confused swaps the open eye for the dizzy swirl.
self.aux = auxIndex or anim.aux
return true
end
-- Ask for a state, but never interrupt one that outranks it. A faint is
-- final, and an entrance cannot be cut short by the standby loop it hands
-- on to.
local RANK = { idle = 0, entrance = 1, attack = 2, faint = 3 }
function StadiumMon:request(state, animIndex, auxIndex)
if not self.model then return false end
local now = RANK[self.state] or 0
local want = RANK[state] or 0
if self.state == "faint" then return false end
-- an equal-ranked request RESTARTS: the second move of a two-hit turn
-- should swing again rather than be swallowed by the first
if want < now then return false end
return self:play(state, animIndex, auxIndex)
end
-- The animation a move plays for this species, from the battle system's own
-- per-species table (model_extract's moves.json, packed into the .dsm).
-- `moveIndex` is the Gen 1 move id, which the engine's move defs carry as
-- `index` -- the same numbering, so no name mapping is needed.
function StadiumMon:attack(moveIndex)
local model = self.model
if not (model and moveIndex and moveIndex >= 1
and moveIndex <= StadiumPack.N_MOVES) then
return false
end
local index = model.moveAnim[moveIndex]
if not index or index == StadiumPack.NONE then return false end
local aux = model.moveAux[moveIndex]
return self:request("attack", index + 1,
(aux and aux >= 0) and (aux + 1) or nil)
end
-- ------- per frame
function StadiumMon:update(dt)
-- kept for build(), which runs later in the same frame and needs it to
-- advance the anchor's filter (StadiumRig.anchor). Stashed before the
-- early-outs below, so a species with nothing to play still has one.
self.dt = dt or 0
-- the ball-to-full-size ramp, which runs whether or not there is an
-- animation to play alongside it
if self.grow then
self.grow = self.grow + (dt or 0) / StadiumMon.GROW_TIME
if self.grow >= 1 then self.grow = nil end
end
local model = self.model
if not (model and self.anim) then return end
local anim = model.anims[self.anim]
if not anim then return end
self.time = self.time + (dt or 0)
if self.time >= anim.seconds and not self.loop then
if self.hold then
-- a faint stays down: hold the last frame rather than snapping back
-- to a standing pose the moment the animation runs out
self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS)
-- and SAY so, once. The clamp above means the clock can no longer be
-- asked whether the animation is over -- it stops a frame short of the
-- end and stays there forever -- and something has to know, because a
-- collapse that has finished is the moment the Pokemon may leave the
-- field (see Stadium's onField).
self.done = true
else
local nextState = (STATES[self.state] or {}).next or "idle"
self:play(nextState)
end
end
end
-- ------- the grow
--
-- Begin coming out of the ball. Answers whether it actually started, so the
-- caller can play the entrance alongside it and the engine's own send-out
-- seam a moment later does not restart what is already running.
function StadiumMon:beginGrow()
if self.grow or not self.model then return false end
self.grow = 0
-- and remember that THIS arrival was ours to size, so the engine's own
-- three-step ramp is not consulted again for it. Ours starts earlier and
-- finishes a few frames sooner, and in that gap the engine's ramp still
-- reads 5/7 -- so falling back to it shrank the Pokemon from 0.96 back to
-- 0.71 and then snapped it to full, a visible hitch at the end of an
-- animation that exists to not have one.
self.grewOwn = true
return true
end
-- How big this Pokemon is drawn this frame, as a fraction of its real size.
--
-- Smoothstep rather than a straight ramp or an ease-out: the ball is opening
-- for the first half of this, so a curve that is already near full size by
-- then would have the Pokemon standing there while the ball is still coming
-- apart. Slow, then quick through the middle, then settling exactly as the
-- engine's own grow ends.
function StadiumMon:growScale()
local t = self.grow
if not t then return 1 end
if t <= 0 then return 0 end
if t >= 1 then return 1 end
return t * t * (3 - 2 * t)
end
-- Whether a HELD animation -- which in practice means a faint -- has played
-- all the way through and is now sitting on its last frame. Always false for
-- a looping one, which never finishes, and for one that hands on to another
-- state, which has already stopped being itself by the time anyone can ask.
function StadiumMon:finished()
return self.done and true or false
end
-- How tall this species stands on the map, in world pixels.
function StadiumMon:worldHeight()
local model = self.model
local h = model and model.height or 0
if not (h > 0) then return StadiumMon.REF_HEIGHT end
local k = (h / StadiumMon.MEDIAN) ^ StadiumMon.SQUASH
local out = StadiumMon.REF_HEIGHT * k
if out < StadiumMon.MIN_HEIGHT then out = StadiumMon.MIN_HEIGHT end
if out > StadiumMon.MAX_HEIGHT then out = StadiumMon.MAX_HEIGHT end
return out
end
-- How wide this Pokemon stands, in world pixels -- the same scale
-- worldHeight is in, so a caller can size something to its footprint.
--
-- Only STADIUM B asks: it needs to know how big a platform to put under a
-- mon, and "as tall as it is" is the wrong answer for a Snorlax, which is
-- half as tall as an Onix and three times as wide.
--
-- The send-out grow is deliberately NOT folded in. A Pokemon scaling up out
-- of its ball should arrive on a platform that was already there, not one
-- that inflates under its feet.
function StadiumMon:worldRadius()
local model = self.model
if not model then return 0 end
local h = model.height or 0
if not (h > 0) then return 0 end
return (model.radius or 0) * self:worldHeight() / h
end
-- The model matrix: stand this Pokemon on world (x, groundY, z) facing
-- (faceX, faceZ), at whatever the send-out grow has done to its size.
--
-- The vertices the rig writes are in the model's RAW units -- before the
-- model_root scale the game applies -- so the scale here carries that too,
-- and the floor offset is measured in the same raw units on the way in.
function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
local model = self.model
if not model 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)
-- stand it on its own lowest point, then give back as much of the
-- authored hover as the shot can hold (see the header)
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
local yaw = 0
if faceX and faceZ and (faceX ~= 0 or faceZ ~= 0) then
-- the card and the model share this convention: an unrotated model
-- faces +Z, which is map SOUTH, which is what "facing down" is in the
-- flat game (see Voxel3D's axis note)
yaw = math.atan2(faceX, faceZ)
end
self.yaw = yaw
return Mat4.mul(
Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
Mat4.scale(k, k, k)),
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
-- doing on the CPU.
function StadiumMon:build()
if not (self.rig and self.model) then return false end
-- self.anim is nil while a species has nothing to play, and pose() reads
-- that as "the bind pose", which is exactly what is wanted
self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop)
-- and then back onto the tile, because these animations were authored for
-- a camera that followed the Pokemon and this one does not move (see
-- StadiumRig.anchor)
self.rig:anchor(StadiumMon.TRAVEL, self.dt)
self.rig:skin(self.yaw or 0)
-- no clock of its own: the texture animation rides the frame pose() just
-- resolved, which is what keeps a blink inside its standby loop and a
-- fainted Pokemon's eyes shut once it has stopped moving
self.rig:textures(self.aux)
return true
end
return StadiumMon
+661
View File
@@ -0,0 +1,661 @@
-- STADIUM battles: reading one species' model off disk.
--
-- `NNN.dsm` holds one Pokemon Stadium battle model. It is written by
-- StadiumBuild, out of the player's own copy of that ROM, the first time the
-- mod runs (see StadiumInstall) -- and by tools/stadium_pack.py, which is the
-- oracle that Lua path is tested against. This file is the other half of that
-- format and nothing else: bytes in, tables out. What the tables MEAN is
-- StadiumRig's business (posing a skeleton) and StadiumMon's (which animation
-- a fight is asking for).
--
-- Three things shape it.
--
-- BINARY, NOT LUA. A species is a couple of hundred kilobytes of numbers,
-- most of it animation, and a Lua source file of that is a parse the loader
-- would pay for on every boot whether a battle happened or not. A byte
-- string is read once, on the frame a fight starts, and only for the two
-- species actually fighting.
--
-- LAZY ANIMATIONS. Geometry, bones and textures are decoded on load --
-- they are small, and every one of them is needed the moment the mon
-- appears. The animations are not: a fight uses idle, an entrance and
-- whichever handful of attacks come up, out of the seven to twenty-one a
-- species carries. So the load pass SCANS the animation block, recording
-- where each one starts and skipping the rest, and a track is decoded the
-- first time something plays it. That turns a 200 KB decode into a 20 KB
-- one plus a few milliseconds spread over the fight.
--
-- AN LRU OF FOUR. A model is shared by everything that draws that species
-- -- both sides of a mirror match, both VR eyes -- and kept for a few
-- battles after, because the next fight on the same route is very often
-- the same Pokemon. Four is enough for a wild fight (two) plus the
-- trainer's next two, and it bounds what the mode can hold to a few
-- megabytes.
--
-- Everything is pcall-guarded and every failure answers nil: a missing
-- pack, a truncated file or a driver that will not make an image all end
-- at the same place, which is the flat 2D-3D card this mode falls back to
-- (see Stadium).
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumPack = {}
local byte = string.byte
local floor = math.floor
-- ------- where a pack comes from
--
-- Two places, asked in this order.
--
-- CACHE_DIR is in the save directory and is what actually ships: the mod
-- carries no models (they are Pokemon Stadium's data), so StadiumInstall
-- builds them out of the player's own ROM on first run and writes them here.
--
-- DIR is inside the mod, and exists for a developer checkout that has run
-- tools/stadium_pack.py -- which is also how the oracle the Lua extractor is
-- tested against gets built. It is second because a locally built CURRENT
-- cache should win over whatever a checkout happens to have lying around --
-- current as judged by StadiumInstall's marker, so a cache an old extractor
-- built does not shadow a fresh set (see readPack).
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
StadiumPack.DIR = "assets/stadium"
-- 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 = 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 (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
if not (mod and mod.read) then return nil end
local ok, bytes = pcall(mod.read, mod,
packName(StadiumPack.DIR, species, shiny))
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
return nil
end
-- The battle system's context slots, in the order tools/stadium_pack.py
-- writes them -- slot 165 upward (see model_extract/manifest.json's
-- animationSlots). Indexed by POSITION, so this list is the format's
-- contract and the packer's CONTEXTS must stay identical to it.
-- Position 2 was called "hit" until the move table was read against it: it
-- is the animation most of a species' MOVES play, which makes it the default
-- attack and not a damage reaction (see StadiumMon's STATES). The slot TABLE
-- is indexed by position, but the name also reaches the packed files: the
-- packers bake it into the animation NAME strings, so it has to match
-- tools/stadium_pack.py's CONTEXTS *and* pipeline/battle.py's CONTEXT_SLOTS,
-- or the oracle diff reports every species.
StadiumPack.CONTEXT = {
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
"struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
"reaction_180", "reaction_181", "reaction_182", "entrance_alt",
"idle_return",
}
-- name -> slot position, for callers that ask by name
StadiumPack.SLOT = {}
for i, name in ipairs(StadiumPack.CONTEXT) do StadiumPack.SLOT[name] = i end
StadiumPack.N_MOVES = 165
StadiumPack.NONE = 0xFFFF
-- The frame rate every animation in the set is authored at
-- (model_extract/README.md: keyframe times are frame / 30).
StadiumPack.FPS = 30
-- ------- readers
--
-- One cursor threaded through by hand rather than an object: this runs over
-- a couple of hundred thousand values on the frame a battle starts, and a
-- method call per value is the difference between a hitch and no hitch.
local function u8(s, p) return byte(s, p), p + 1 end
local function u16(s, p)
local a, b = byte(s, p, p + 1)
return a + b * 256, p + 2
end
local function i16(s, p)
local a, b = byte(s, p, p + 1)
local v = a + b * 256
if v >= 32768 then v = v - 65536 end
return v, p + 2
end
local function u32(s, p)
local a, b, c, d = byte(s, p, p + 3)
return a + b * 256 + c * 65536 + d * 16777216, p + 4
end
local function i32(s, p)
local v
v, p = u32(s, p)
if v >= 2147483648 then v = v - 4294967296 end
return v, p
end
-- IEEE 754 single, by hand. LOVE has love.data.unpack, but this file reads
-- exactly four floats per model (the header's extents) and a hand decode
-- costs nothing while removing a version floor from the mod's whole
-- STADIUM path.
local function f32(s, p)
local b1, b2, b3, b4 = byte(s, p, p + 3)
local sign = 1
if b4 >= 128 then sign, b4 = -1, b4 - 128 end
local expo = b4 * 2 + floor(b3 / 128)
local mant = (b3 % 128) * 65536 + b2 * 256 + b1
if expo == 255 then
if mant == 0 then return sign * math.huge, p + 4 end
return 0, p + 4
end
if expo == 0 then return sign * mant * 2 ^ -149, p + 4 end
return sign * (1 + mant / 8388608) * 2 ^ (expo - 127), p + 4
end
-- 16.16 fixed point, which is how bone scales are stored (they run from
-- about -31 to 100 across the set and a float would cost twice the bytes
-- for precision nothing can see).
local function fixed(s, p)
local v
v, p = i32(s, p)
return v / 65536, p
end
-- ------- the load
local function readHeader(s, p, model)
model.species, p = u16(s, p)
model.boneCount, p = u16(s, p)
model.primCount, p = u16(s, p)
model.texCount, p = u16(s, p)
model.animCount, p = u16(s, p)
model.auxCount, p = u16(s, p)
model.rootScale, p = f32(s, p)
-- a species whose standby loop is corrupt in the source extraction, and
-- which the mod therefore holds at its bind pose (see the packer's
-- idle_is_broken). Three of the 151.
local static
static, p = u8(s, p)
model.staticPose = static ~= 0
model.height, p = f32(s, p)
model.floor, p = f32(s, p)
model.radius, p = f32(s, p)
local moveAnim, moveAux, ctx = {}, {}, {}
for i = 1, StadiumPack.N_MOVES do moveAnim[i], p = u16(s, p) end
for i = 1, StadiumPack.N_MOVES do moveAux[i], p = i16(s, p) end
for i = 1, #StadiumPack.CONTEXT do ctx[i], p = u16(s, p) end
model.moveAnim, model.moveAux, model.ctx = moveAnim, moveAux, ctx
return p
end
-- The bone tree, as flat parallel arrays: a rig walk touches every bone
-- every frame and an array of little tables would be a cache miss per bone
-- and a table per bone to collect.
local function readBones(s, p, model)
local n = model.boneCount
local parent, t, r, sc = {}, {}, {}, {}
for i = 1, n do
-- 0-based in the file, 1-based here, and 0 for "no parent" so the rig's
-- walk can test it without a sentinel comparison
local par
par, p = i16(s, p)
parent[i] = par + 1
local b = (i - 1) * 3
t[b + 1], p = i16(s, p)
t[b + 2], p = i16(s, p)
t[b + 3], p = i16(s, p)
r[b + 1], p = i16(s, p)
r[b + 2], p = i16(s, p)
r[b + 3], p = i16(s, p)
sc[b + 1], p = fixed(s, p)
sc[b + 2], p = fixed(s, p)
sc[b + 3], p = fixed(s, p)
end
model.parent, model.restT, model.restR, model.restS = parent, t, r, sc
return p
end
-- One drawable piece: the triangles that share a texture and a cull mode.
--
-- Positions and normals stay in BONE-LOCAL space, exactly as the display
-- list had them, because that is what makes the skinning a single matrix
-- multiply per vertex (every vertex in the set is rigidly bound to one bone
-- -- see model_extract/README.md) rather than a weighted blend.
local function readPrims(s, p, model)
local prims = {}
for i = 1, model.primCount do
local prim = {}
prim.tex, p = u16(s, p)
prim.tex = prim.tex + 1
local cull, blend
cull, p = u8(s, p)
blend, p = u8(s, p)
prim.cull = cull ~= 0
prim.additive = blend ~= 0
prim.texAnim, p = i16(s, p)
-- the texture-animation channel's value -> which texture to swap in.
-- Keyed by the stream's own byte, so the rig can look one up without
-- searching.
local mapN
mapN, p = u8(s, p)
if mapN > 0 then
local map = {}
for _ = 1, mapN do
local key, tex
key, p = u8(s, p)
tex, p = u16(s, p)
map[key] = tex + 1
end
prim.texMap = map
end
local fxN
fxN, p = u16(s, p)
if fxN > 0 then
local frames = {}
for k = 1, fxN do
frames[k], p = u16(s, p)
frames[k] = frames[k] + 1
end
prim.fxFrames = frames
end
local nv, ni
nv, p = u16(s, p)
ni, p = u16(s, p)
prim.vertCount, prim.indexCount = nv, ni
-- five arrays rather than one array of vertices, for the same reason
-- the bones are flat: the skinning loop reads them in step and writes
-- one LOVE vertex row out
local px, py, pz = {}, {}, {}
local uv = {}
local nx, ny, nz = {}, {}, {}
local bone = {}
for k = 1, nv do
px[k], p = i16(s, p)
py[k], p = i16(s, p)
pz[k], p = i16(s, p)
local u, v
u, p = i16(s, p)
v, p = i16(s, p)
uv[k * 2 - 1], uv[k * 2] = u / 512, v / 512
local a, b, c
a, p = u8(s, p)
b, p = u8(s, p)
c, p = u8(s, p)
if a >= 128 then a = a - 256 end
if b >= 128 then b = b - 256 end
if c >= 128 then c = c - 256 end
nx[k], ny[k], nz[k] = a / 127, b / 127, c / 127
bone[k], p = u8(s, p)
bone[k] = bone[k] + 1
end
prim.px, prim.py, prim.pz = px, py, pz
prim.uv = uv
prim.nx, prim.ny, prim.nz = nx, ny, nz
prim.bone = bone
local idx = {}
for k = 1, ni do
idx[k], p = u16(s, p)
idx[k] = idx[k] + 1
end
prim.index = idx
prims[i] = prim
end
model.prims = prims
return p
end
-- The textures, kept as the raw RGBA8 they arrived as and turned into
-- images on first use. A species carries every frame of every blink and
-- every dizzy swirl; a fight that never shows one should not pay to
-- upload it.
--
-- Raw rather than PNG, which is what DSM3 changed: an ImageData over these
-- bytes is a memcpy where a PNG is a decode on the frame a battle starts,
-- and -- the reason it was actually done -- uncompressed pixels are the same
-- pixels whichever side wrote them, so the Lua extractor's output can be
-- diffed against the Python packer's byte for byte. Two deflate
-- implementations need not agree; two arrays of pixels do.
local function readTextures(s, p, model)
local tex = {}
for i = 1, model.texCount do
local w, h, len
w, p = u16(s, p)
h, p = u16(s, p)
len, p = u32(s, p)
tex[i] = { w = w, h = h, rgba = s:sub(p, p + len - 1) }
p = p + len
end
model.textures = tex
return p
end
-- How many bytes one animation's track block occupies, without decoding
-- any of it. This is the scan that makes lazy animations possible: nine
-- components a bone, each either one value or one a frame, and the only
-- thing that has to be READ is the byte that says which.
local COMP_BYTES = { 2, 2, 2, 2, 2, 2, 4, 4, 4 } -- t t t r r r s s s
local function skipTracks(s, p, boneCount, frames)
for _ = 1, boneCount do
local present
present, p = u8(s, p)
if present ~= 0 then
for c = 1, 9 do
local kind
kind, p = u8(s, p)
p = p + COMP_BYTES[c] * (kind == 0 and 1 or frames)
end
end
end
return p
end
local function readAnims(s, p, model)
local anims = {}
for i = 1, model.animCount do
local len
len, p = u8(s, p)
local name = s:sub(p, p + len - 1)
p = p + len
local frames, loopStart, aux
frames, p = u16(s, p)
loopStart, p = u16(s, p)
aux, p = i16(s, p)
anims[i] = {
name = name, frames = frames, loopStart = loopStart,
aux = aux >= 0 and (aux + 1) or nil,
seconds = frames / StadiumPack.FPS,
offset = p, -- where its tracks start; decoded later
}
p = skipTracks(s, p, model.boneCount, frames)
end
model.anims = anims
return p
end
local function readAux(s, p, model)
local aux = {}
for i = 1, model.auxCount do
local frames, loopStart, chanN
frames, p = u16(s, p)
loopStart, p = u16(s, p)
chanN, p = u16(s, p)
local chans = {}
for c = 1, chanN do
local n
n, p = u16(s, p)
local stream = {}
for k = 1, n do stream[k], p = u16(s, p) end
chans[c] = stream
end
aux[i] = { frames = frames, loopStart = loopStart, channels = chans }
end
model.auxAnims = aux
return p
end
-- ------- a track block, decoded on demand
--
-- The shape a pose walk wants: `tracks[bone]` is either nil (this bone
-- holds its rest transform for the whole animation) or nine entries, each
-- either a number (constant) or an array of one value per frame.
--
-- That fold is the source data's own, not something imposed here: a bone
-- that only rotates costs two bytes for each of its six other components,
-- and across the 151 species it is most of the reason the whole set is 24
-- megabytes rather than a hundred.
function StadiumPack.tracks(model, index)
local anim = model.anims and model.anims[index]
if not anim then return nil end
if anim.tracks then return anim.tracks end
local s, p = model.bytes, anim.offset
if not (s and p) then return nil end
local frames = anim.frames
local out = {}
for b = 1, model.boneCount do
local present
present, p = u8(s, p)
if present ~= 0 then
local comps = {}
for c = 1, 9 do
local kind
kind, p = u8(s, p)
local read = (c >= 7) and fixed or i16
if kind == 0 then
comps[c], p = read(s, p)
else
local arr = {}
for k = 1, frames do arr[k], p = read(s, p) end
comps[c] = arr
end
end
out[b] = comps
end
end
anim.tracks = out
return out
end
-- One texture as a LOVE image, decoded on first ask.
function StadiumPack.image(model, index)
local slot = model.textures and model.textures[index]
if not slot then return nil end
if slot.image ~= nil then return slot.image or nil end
local ok, img = pcall(function()
local data = love.image.newImageData(slot.w, slot.h, "rgba8", slot.rgba)
local image = love.graphics.newImage(data)
-- N64 art at N64 resolution: nearest keeps the texels the size the
-- artist drew them, exactly as every other texture in this mode
image:setFilter("nearest", "nearest")
return image
end)
slot.image = (ok and img) or false
return slot.image or nil
end
-- ------- the cache
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)
for i = #order, 1, -1 do
if order[i] == species then table.remove(order, i) end
end
order[#order + 1] = species
while #order > StadiumPack.KEEP do
local drop = table.remove(order, 1)
local model = cache[drop]
cache[drop] = nil
if model and model.textures then
for _, slot in ipairs(model.textures) do
if slot.image and slot.image.release then
pcall(slot.image.release, slot.image)
end
-- CLEARED, not just released. A released Image is still a truthy
-- value, and `image()` below hands back whatever is in this field
-- without looking at it -- so leaving the corpse here meant the next
-- ask returned a dead object, which reached mesh:setTexture and threw
-- "Cannot use object after it has been released" from inside the
-- scene pass. Nil means the next ask decodes it again, which is the
-- whole point of the slot being lazy.
slot.image = nil
end
end
end
end
-- Say that this species is IN USE, so the cache does not evict it.
--
-- The eviction order above is a least-recently-LOADED list, not a
-- least-recently-used one: `touch` runs from `load`, and `load` is only
-- reached when a side's species CHANGES (StadiumMon.setSpecies returns early
-- otherwise). A Pokemon that stands on the field for several turns therefore
-- never refreshes its position, drifts to the front of the queue, and is
-- evicted -- its textures released -- while it is still being drawn sixty
-- times a second. That is what a fifth species entering a battle did: call
-- out a Clefairy and whatever had been standing longest lost its textures
-- mid-fight.
--
-- 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, 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.
--
-- 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.
--
-- `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 key = cacheKey(species, shiny)
local hit = cache[key]
if hit ~= nil then
touch(key)
return hit or nil
end
local bytes = readPack(species, shiny)
if not bytes and shiny then
return StadiumPack.load(species, false)
end
if not bytes then
cache[key] = false
return nil
end
local ok, model = pcall(function()
if bytes:sub(1, 4) ~= "DSM3" then
error("not a DSM3 pack -- delete it and let the mod rebuild it", 0)
end
local m = { bytes = bytes }
local p = 5
p = readHeader(bytes, p, m)
p = readBones(bytes, p, m)
p = readPrims(bytes, p, m)
p = readTextures(bytes, p, m)
p = readAnims(bytes, p, m)
readAux(bytes, p, m)
return m
end)
if not ok then
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
model.shiny = shiny and true or nil
cache[key] = model
touch(key)
return model
end
-- Drop everything (hot reload, or a graphics context that went away).
function StadiumPack.invalidate()
for _, model in pairs(cache) do
if model and model.textures then
for _, slot in ipairs(model.textures) do
if slot.image and slot.image.release then
pcall(slot.image.release, slot.image)
end
slot.image = nil
end
end
end
end
function StadiumPack.forget()
StadiumPack.invalidate()
cache, order = {}, {}
end
return StadiumPack
+864
View File
@@ -0,0 +1,864 @@
-- STADIUM battles: posing a skeleton and skinning it, on the CPU.
--
-- One instance of this is one Pokemon standing on the map -- the meshes it
-- draws through and the scratch space its pose is computed in. The MODEL
-- (geometry, bones, animations, textures) is shared and read-only; this is
-- everything about it that is per-Pokemon and changes every frame.
--
-- ------- why the CPU
--
-- Because these models are tiny and the mod's shader already exists. A
-- battle model is 674 vertices on average and 1311 at the worst, of which
-- exactly two are on screen at a time -- so skinning them by hand costs
-- about two thousand vertex transforms a frame, which is less than the
-- grass pass does on an empty route. What it buys is that the finished
-- vertices go into Voxel3D's OWN vertex format, through Voxel3D's OWN
-- shader, and therefore get every single thing the rest of the diorama
-- gets for free: the depth buffer decides what is in front of what, the
-- sun pass throws a real shadow of the actual pose, the hour's tint lands
-- on it, the hit flash flattens it, and the tilt-shift and the
-- depth-of-field see it as part of the picture. A GPU skinning path would
-- have needed a second shader that then had to re-implement all of that,
-- and a second shadow shader beside it.
--
-- It is also what makes the FORMAT work. Every vertex in the Stadium set is
-- rigidly bound to ONE bone with weight 1 (model_extract/README.md), so
-- skinning is a single matrix multiply per vertex with no blend -- and the
-- per-vertex `shade` Voxel3D wants, which no glTF has, is computed here
-- from the bone-local normal.
--
-- ------- the two matrix chains
--
-- The game keeps bone scale OUT of the matrix chain (func_800143C0): scale
-- accumulates in its own stack, a bone's local translation is
-- pre-multiplied by its parent's accumulated scale, and a bone's own
-- accumulated scale is applied to the finished matrix only at draw time.
-- glTF cannot express that -- its node scale propagates to children -- and
-- the reference export works around it by splitting every bone into two
-- nodes.
--
-- Here it falls out naturally, as two arrays:
--
-- pivot rotation and translation only. This is what a CHILD inherits,
-- and it is a pure rotation, which is also why the normals are
-- transformed with it rather than with the draw matrix.
-- draw the same matrix with the bone's accumulated scale applied on
-- the right, which is the one vertices go through.
--
-- Folding the scale into the chain instead is the obvious mistake and it
-- applies every ancestor's scale once per generation. It is caught by the
-- suite: tools/stadium_pack.py measures the bind pose with this exact walk
-- and its answer matches the verified glTF export on all 151 species.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Voxel3D = V.require("Voxel3D")
local StadiumPack = V.require("StadiumPack")
local StadiumRig = {}
StadiumRig.__index = StadiumRig
local sin, cos, floor = math.sin, math.cos, math.floor
-- binary angle (32768 = pi) to radians
local ANG = math.pi / 32768
-- ------- how a surface is lit
--
-- Voxel3D shades a face by its DIRECTION rather than by a light uniform:
-- every terrain and character mesh in this mode carries a per-vertex
-- `shade` baked from which way its face points, and the shadow map
-- multiplies on top of that (see Voxel3D.FACE_SHADE). A skinned model has
-- no fixed faces to bake, so the same answer is computed per vertex from
-- the posed normal -- and these four numbers are FACE_SHADE's own six
-- values, fitted:
--
-- +Y up 1.00 -Y down 0.55 +X east 0.84 -X west 0.72
-- +Z south 0.90 -Z north 0.68
--
-- so a Pokemon's flank catches the same southeastern sun the roof of the
-- house behind it does, and the two read as being in one picture.
local SHADE_BASE = 0.7725
local SHADE_X = 0.06
local SHADE_Y = 0.225
local SHADE_Z = 0.11
-- ------- an instance
-- `model` is a StadiumPack model. Returns nil where meshes cannot be made,
-- which is the same "no 3D" answer every other GPU object in this mod gives.
function StadiumRig.new(model)
if not (model and model.prims) then return nil end
if not (love.graphics and love.graphics.newMesh) then return nil end
local self = setmetatable({
model = model,
-- The two chains, flat: twelve numbers a bone, row-major 3x4.
--
-- Named with the M rather than `pivot` and `draw` because an instance
-- field called `draw` shadows the DRAW METHOD through __index, and the
-- failure that causes is a nasty one: the shadow pass calls caster()
-- and keeps working, so a Pokemon casts a perfect animated shadow onto
-- ground it is not standing on.
pivotM = {},
drawM = {},
-- the accumulated scale, which is the third thing the game's own walk
-- carries and neither matrix can hold
accX = {}, accY = {}, accZ = {},
parts = {},
-- what the pose walk last answered, so a frame that neither moved the
-- animation nor turned the model can skip the whole thing
poseKey = nil,
-- scratch for the body-centre estimate (see anchor), kept on the rig so
-- a per-frame measurement allocates nothing
cx = {}, cy = {}, cz = {},
}, StadiumRig)
-- One mesh per primitive: a primitive is already "the triangles sharing
-- one texture", which is exactly one draw call's worth.
--
-- "dynamic" rather than "static": every vertex is rewritten every frame
-- the pose changes, which is what the usage hint exists to say.
for i, prim in ipairs(model.prims) do
local rows = {}
local uv = prim.uv
for k = 1, prim.vertCount do
-- position and shade are filled by skin(); the texture coordinates
-- never change, so they are written once here
rows[k] = { 0, 0, 0, uv[k * 2 - 1], uv[k * 2], 1 }
end
local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.FORMAT, rows,
"triangles", "dynamic")
if not ok then return nil end
pcall(mesh.setVertexMap, mesh, prim.index)
self.parts[i] = { mesh = mesh, rows = rows, prim = prim }
end
-- the spot the animations are measured against, taken while there is no
-- pose to overwrite (see measureBind)
pcall(self.measureBind, self)
return self
end
function StadiumRig:release()
for _, part in ipairs(self.parts or {}) do
if part.mesh and part.mesh.release then
pcall(part.mesh.release, part.mesh)
end
end
self.parts = {}
end
-- ------- sampling one track
--
-- `c` is the pack's own fold: a bare number when the component holds still
-- for the whole animation, or one value a frame when it does not. Two frame
-- indices and a blend come in because the caller has already resolved what
-- "between frame 12 and 13, three tenths of the way" means for THIS
-- animation's looping.
-- One component at one frame.
local function sampleAt(c, i)
if type(c) == "number" then return c end
return c[i]
end
-- ------- interpolation, and the one place it must not happen
--
-- These streams are not keyframes: they carry ONE VALUE PER FRAME at 30 Hz,
-- and the game steps them a frame at a time. So at 60 Hz the honest replay
-- is each pose held for two frames -- which is exactly what it looks like,
-- a set of models moving at half the frame rate of everything around them.
-- Blending between consecutive entries is therefore not reconstructing
-- something the source had; it is INVENTING the halfway pose. It is worth
-- inventing, because a 30 Hz step against a 60 Hz camera reads as a stutter
-- and the halfway pose is right far more often than it is wrong.
--
-- Where it IS wrong is the reason a naive version of this shipped once and
-- had to be taken out: bones snapping to an upside-down pose for a frame,
-- arms turning inside out for a few. Rotations here are EULER TRIPLES, and
-- a Euler triple is not a direction you can walk along. Two triples can
-- describe nearly the same orientation and be nowhere near each other
-- component by component -- (0, 20976, 32736) and (0, -19936, -5904) are a
-- real pair out of the set -- so walking from one to the other passes
-- through orientations that are nothing like either end. That is precisely
-- a bone flipping over and back inside one frame.
--
-- Shortest-arc wrapping (below) fixes the easy half of that, where a
-- component crosses the +-pi seam. It cannot fix the hard half, where the
-- source simply RE-EXPRESSES a rotation. So the hard half is not fixed, it
-- is DETECTED: a bone whose rotation moves more than BREAK_ANGLE in a
-- single frame is not being animated, it is being re-expressed or snapped,
-- and that bone holds its frame instead of blending. Per bone and all three
-- components together, because the three are one rotation and blending two
-- of them while holding the third is its own wrong answer.
--
-- The same guard, in the same spirit, for TRANSLATION: BREAK_MOVE of the
-- model's own height inside one frame is a teleport rather than a stride.
-- Scale needs none -- a linear blend of two scales lies between them, and
-- there is no way for that to be a pose neither end had.
-- 32768 binary-angle units is pi, so this is a quarter turn in one 30 Hz
-- frame -- 2700 degrees a second. Nothing in the set genuinely moves that
-- fast; everything that reads as moving that fast is a re-expression.
local BREAK_ANGLE = 16384
-- and half the Pokemon's own height in one frame, which is fifteen body
-- heights a second
local BREAK_MOVE = 0.5
-- The signed distance from `c[i0]` to `c[i1]` the SHORT way round, for a
-- binary angle. Interpolating 32700 toward -32700 the long way spins the
-- bone most of a full turn inside one frame; the short way is 136 units,
-- which is what actually happened.
local function angleDelta(c, i0, i1)
if type(c) == "number" then return 0 end
local d = c[i1] - c[i0]
if d > 32768 then d = d - 65536 elseif d < -32768 then d = d + 65536 end
return d
end
local function linearDelta(c, i0, i1)
if type(c) == "number" then return 0 end
return c[i1] - c[i0]
end
-- ------- the pose
--
-- `anim` is an index into model.anims (or nil for the bind pose), `frame` a
-- FLOAT frame in that animation's own 30 Hz timeline, and `wrap` whether
-- the far end joins back to loopStart (a standby loop) or holds on the last
-- frame (a faint).
function StadiumRig:pose(anim, frame, wrap)
local model = self.model
local n = model.boneCount
local tracks = anim and StadiumPack.tracks(model, anim) or nil
local frames = anim and model.anims[anim] and model.anims[anim].frames or 1
-- The two frames this instant falls between, and how far. `k` is 0 on
-- every whole frame, so a caller that steps in whole frames -- the test
-- suite, the blink probe -- sees exactly the frame it asked for.
local i0, i1, k = 1, 1, 0
if tracks and frames > 1 then
local f = frame
if f < 0 then f = 0 end
local base = floor(f)
k = f - base
local loop = model.anims[anim].loopStart or 0
if not (loop > 0 and loop < frames) then loop = 0 end
if base >= frames then
if wrap then
-- the far end joins back to loopStart, which is where the game's own
-- player sends the counter (func_80016FBC)
base = loop + (base - loop) % (frames - loop)
else
base = frames - 1 -- a faint holds where it fell
k = 0
end
end
i0 = base + 1
if i0 > frames then i0 = frames end
if i0 < 1 then i0 = 1 end
-- and the frame after it, which past the end of a loop is loopStart --
-- the same seam the counter itself crosses. An animation that HOLDS
-- (a faint) has nothing after its last frame, so it blends with itself.
if i0 < frames then
i1 = i0 + 1
elseif wrap then
i1 = loop + 1
else
i1, k = i0, 0
end
end
-- The frame this animation is actually SHOWING, after the wrap or the
-- hold, 0-based -- the WHOLE frame, never the blend. A texture swap has no
-- halfway: an eye is open or it is shut, and a pupil interpolated toward a
-- swirl is not a thing the hardware could draw. So the skeleton runs at 60
-- and the textures step at 30, which is what the game does with both.
-- Stashed rather than recomputed because the texture
-- animation is sampled at the very same frame (see textures) -- in the
-- game one counter drives both, and 73% of the paired animations in the
-- set are the same length as each other, which is what that looks like
-- from the outside. Two copies of this arithmetic would be two things to
-- keep in step; one number cannot drift from itself.
self.frameAt = i0 - 1
local parent = model.parent
local restT, restR, restS = model.restT, model.restR, model.restS
local pivot, drw = self.pivotM, self.drawM
local accX, accY, accZ = self.accX, self.accY, self.accZ
-- how far a bone may travel in one frame before it is read as a teleport
-- rather than a stride. In the vertices' own RAW units, which is what the
-- tracks are in: model.height is measured after the model_root scale.
local moveBreak = nil
if k > 0 then
local root = model.rootScale
if not (root and root > 0) then root = 1 end
local h = (model.height or 0) / root
if h > 0 then moveBreak = h * BREAK_MOVE end
end
for b = 1, n do
local o3 = (b - 1) * 3
local tx, ty, tz, rx, ry, rz, kx, ky, kz
local comps = tracks and tracks[b]
if comps then
tx = sampleAt(comps[1], i0)
ty = sampleAt(comps[2], i0)
tz = sampleAt(comps[3], i0)
rx = sampleAt(comps[4], i0)
ry = sampleAt(comps[5], i0)
rz = sampleAt(comps[6], i0)
kx = sampleAt(comps[7], i0)
ky = sampleAt(comps[8], i0)
kz = sampleAt(comps[9], i0)
if k > 0 then
-- ROTATION, all three at once: a bone that snaps holds its frame,
-- and a bone that moves holds none of it (see BREAK_ANGLE)
local dx = angleDelta(comps[4], i0, i1)
local dy = angleDelta(comps[5], i0, i1)
local dz = angleDelta(comps[6], i0, i1)
if dx < 0 then dx = -dx end
if dy < 0 then dy = -dy end
if dz < 0 then dz = -dz end
if dx <= BREAK_ANGLE and dy <= BREAK_ANGLE and dz <= BREAK_ANGLE then
rx = rx + angleDelta(comps[4], i0, i1) * k
ry = ry + angleDelta(comps[5], i0, i1) * k
rz = rz + angleDelta(comps[6], i0, i1) * k
end
-- TRANSLATION, likewise together: the three are one offset
local mx = linearDelta(comps[1], i0, i1)
local my = linearDelta(comps[2], i0, i1)
local mz = linearDelta(comps[3], i0, i1)
local far = false
if moveBreak then
far = (mx > moveBreak or mx < -moveBreak)
or (my > moveBreak or my < -moveBreak)
or (mz > moveBreak or mz < -moveBreak)
end
if not far then
tx, ty, tz = tx + mx * k, ty + my * k, tz + mz * k
end
-- SCALE, which cannot land anywhere the two ends did not bracket
kx = kx + linearDelta(comps[7], i0, i1) * k
ky = ky + linearDelta(comps[8], i0, i1) * k
kz = kz + linearDelta(comps[9], i0, i1) * k
end
else
-- a bone this animation never touches keeps its rest transform
tx, ty, tz = restT[o3 + 1], restT[o3 + 2], restT[o3 + 3]
rx, ry, rz = restR[o3 + 1], restR[o3 + 2], restR[o3 + 3]
kx, ky, kz = restS[o3 + 1], restS[o3 + 2], restS[o3 + 3]
end
local p = parent[b]
local pax, pay, paz = 1, 1, 1
if p > 0 then pax, pay, paz = accX[p], accY[p], accZ[p] end
-- the parent's accumulated scale, applied to the CHILD's offset. This
-- is the whole of what the game does instead of propagating scale.
tx, ty, tz = tx * pax, ty * pay, tz * paz
-- Rx * Ry * Rz in the game's own row-vector form (src/F420.c
-- func_8000F730), written out as the rows of a 3x3
local ax, ay, az = rx * ANG, ry * ANG, rz * ANG
local sx, cx = sin(ax), cos(ax)
local sy, cy = sin(ay), cos(ay)
local sz, cz = sin(az), cos(az)
local m11, m12, m13 = cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz
local m21, m22, m23 = cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz
local m31, m32, m33 = -sy, sx * cy, cx * cy
local o = (b - 1) * 12
if p > 0 then
local q = (p - 1) * 12
local a1, a2, a3, a4 = pivot[q + 1], pivot[q + 2], pivot[q + 3], pivot[q + 4]
local b1, b2, b3, b4 = pivot[q + 5], pivot[q + 6], pivot[q + 7], pivot[q + 8]
local c1, c2, c3, c4 = pivot[q + 9], pivot[q + 10], pivot[q + 11], pivot[q + 12]
pivot[o + 1] = a1 * m11 + a2 * m21 + a3 * m31
pivot[o + 2] = a1 * m12 + a2 * m22 + a3 * m32
pivot[o + 3] = a1 * m13 + a2 * m23 + a3 * m33
pivot[o + 4] = a1 * tx + a2 * ty + a3 * tz + a4
pivot[o + 5] = b1 * m11 + b2 * m21 + b3 * m31
pivot[o + 6] = b1 * m12 + b2 * m22 + b3 * m32
pivot[o + 7] = b1 * m13 + b2 * m23 + b3 * m33
pivot[o + 8] = b1 * tx + b2 * ty + b3 * tz + b4
pivot[o + 9] = c1 * m11 + c2 * m21 + c3 * m31
pivot[o + 10] = c1 * m12 + c2 * m22 + c3 * m32
pivot[o + 11] = c1 * m13 + c2 * m23 + c3 * m33
pivot[o + 12] = c1 * tx + c2 * ty + c3 * tz + c4
else
pivot[o + 1], pivot[o + 2], pivot[o + 3], pivot[o + 4] = m11, m12, m13, tx
pivot[o + 5], pivot[o + 6], pivot[o + 7], pivot[o + 8] = m21, m22, m23, ty
pivot[o + 9], pivot[o + 10], pivot[o + 11], pivot[o + 12] = m31, m32, m33, tz
end
local ex, ey, ez = pax * kx, pay * ky, paz * kz
accX[b], accY[b], accZ[b] = ex, ey, ez
-- the bone's own accumulated scale, on the right: it scales the axes of
-- THIS bone's space and cannot reach the children, which is exactly the
-- game's draw-time application
drw[o + 1], drw[o + 2] = pivot[o + 1] * ex, pivot[o + 2] * ey
drw[o + 3], drw[o + 4] = pivot[o + 3] * ez, pivot[o + 4]
drw[o + 5], drw[o + 6] = pivot[o + 5] * ex, pivot[o + 6] * ey
drw[o + 7], drw[o + 8] = pivot[o + 7] * ez, pivot[o + 8]
drw[o + 9], drw[o + 10] = pivot[o + 9] * ex, pivot[o + 10] * ey
drw[o + 11], drw[o + 12] = pivot[o + 11] * ez, pivot[o + 12]
end
end
-- ------- keeping the Pokemon on its own tile
--
-- Stadium's animations MOVE the Pokemon, and they move it a long way. Half
-- the set's send-out entrances walk the body more than its own height off
-- the spot it started on; Dewgong's faint travels nearly ten body-heights,
-- and its entrance seven and a half. Every one of them ends exactly where it
-- began, because that game framed each Pokemon with a camera of its OWN that
-- followed the performance around a stage.
--
-- This mode has one camera, solved to put two named map cells at two fixed
-- points in a 160x144 frame (BattleCam), and a Pokemon that travels seven
-- body-heights out of that frame is simply GONE -- which is what sending out
-- a Farfetch'd looked like: an empty tile for three and a half seconds,
-- while its animation played somewhere off to the left of the shot.
--
-- So the bulk travel is taken back out. The pose is measured, and whatever
-- has carried the body further than `limit` from where the bind pose put it
-- is subtracted from every bone.
--
-- ------- why a LIMIT and not an anchor
--
-- Pinning the body outright would flatten the animations into mime: a lunge,
-- a hop, a recoil and a collapse are all the body moving, and they are the
-- part worth having. What breaks the shot is not motion, it is EXCURSION --
-- and the two are told apart by how far. Inside the limit nothing is touched
-- at all, so the 83 species whose animations stay put are bit-for-bit what
-- they were; past it the excess alone is removed, so a big move still reads
-- as big and still comes back to the tile it left.
--
-- ------- where the body IS, and why it is not the median
--
-- The first version of this took the median bone origin, on the reasoning
-- that a handful of bones flung anywhere cannot move a median. True, and it
-- had a worse problem: a median is a RANK, and a rank flips. On a bird most
-- of the skeleton is wing, so as the wings beat, which bone sits at the
-- middle of the sorted list swaps between the up cluster and the down one --
-- and the estimate jumps with it. Measured on Pidgey's standby loop the
-- median moved a tenth of a body-height between adjacent half-frames, and on
-- Pidgeot three whole body-heights. The anchor turns that straight into a
-- translation of the ENTIRE Pokemon, so the body counter-shook against its
-- own wings and the flapping read as twice its real speed. That is the
-- "Pidgey's wings flap super fast" this comment exists because of.
--
-- The centre is now the bone origins averaged, WEIGHTED BY HOW MANY VERTICES
-- EACH BONE MOVES. That fixes both halves at once:
--
-- * the weights are a property of the MESH, computed once and never
-- changing, so there is no rank to flip and no discontinuity available
-- to it -- the estimate is as smooth as the bones themselves
-- * a bone with little geometry on it barely counts, which is exactly the
-- robustness the median was for. Farfetch'd's trail is thirty vertices
-- on five bones -- 1.6% of the model -- so streaking three thousand
-- units out moves this by nothing worth measuring
--
-- Against the median it is two to five times smoother on every species
-- tested and measures the same travel to within a few percent.
-- How far the body estimate may move in ONE 30 Hz frame of a species' own
-- standby loop before that species is judged unmeasurable and left
-- unanchored (see measureBind). The fastest genuine motion in the set is
-- about a fifth of a body-height a frame; the one species that fails this
-- moves three.
StadiumRig.ANCHOR_STEADY = 0.5
-- Which context slot the standby loop is, without requiring StadiumPack --
-- this module is below it and a require would be circular. Position 1 of
-- StadiumPack.CONTEXT, which is the format's own contract.
local IDLE_SLOT = 1
-- How much of the model each bone actually carries. Cached on the shared
-- model: it is a fact about the mesh, not about this instance.
local function boneWeights(model)
if model.boneW then return model.boneW, model.boneWTotal end
local w, total = {}, 0
for b = 1, model.boneCount do w[b] = 0 end
for _, prim in ipairs(model.prims) do
local bone = prim.bone
for k = 1, prim.vertCount do
local b = bone[k]
if w[b] then w[b] = w[b] + 1; total = total + 1 end
end
end
model.boneW, model.boneWTotal = w, total
return w, total
end
-- The body centre of the pose currently in drawM.
local function centre(self, n)
local model = self.model
local w, total = boneWeights(model)
if not (total > 0) then return nil end
local x, y, z = 0, 0, 0
local d = self.drawM
for b = 1, n do
local q = w[b]
if q and q > 0 then
local o = (b - 1) * 12
x = x + d[o + 4] * q
y = y + d[o + 8] * q
z = z + d[o + 12] * q
end
end
return x / total, y / total, z / total
end
-- Where the BIND pose puts it -- the spot every animation is measured
-- against. Cached on the shared MODEL, because it is a fact about the model
-- and not about this instance of it.
--
-- Called once, from new(), and deliberately not lazily from anchor(): taking
-- this measurement means POSING the bind pose, which would overwrite the
-- animated pose anchor() was called to correct. Doing it while the rig is
-- still being built is the one moment there is no pose to lose.
function StadiumRig:measureBind()
local model = self.model
if model.bindCX then return end
self:pose(nil, 0, false)
model.bindCX, model.bindCY, model.bindCZ = centre(self, model.boneCount)
-- ------- and whether this species can be anchored at all
--
-- Decided ONCE, per model, offline, by walking its standby loop and asking
-- how far the body estimate moves between one frame and the next.
--
-- Everything the anchor does rests on that estimate being a description of
-- where the Pokemon is. For 147 species it is: the fastest real motion in
-- the set moves the body about a fifth of a body-height per 30 Hz frame.
-- Pidgeot's standby loop moves it THREE, because a few of its rotation
-- frames are junk (the worst data in the set, and a known issue in its own
-- right). There is no filter setting that both tracks a real excursion and
-- rejects that -- measured, at four time constants, either the excursions
-- came back or the shake did -- because the two are only a factor of
-- fifteen apart and a filter is a proportion.
--
-- So a species whose own idle says its estimate cannot be trusted is not
-- anchored, and plays exactly as it did before the anchor existed: it
-- travels as far as its animation says, and it does not vibrate. One
-- species trading a framing problem for no problem beats 147 trading a
-- solved framing problem for a shake.
--
-- Cheap: forty-odd poses on a model that is about to be posed sixty times
-- a second anyway.
local idle = model.ctx and model.ctx[IDLE_SLOT]
local anim = (idle and idle ~= 0xFFFF) and (idle + 1) or nil
local rec = anim and model.anims and model.anims[anim]
model.anchorOk = true
if rec and rec.frames and rec.frames > 1 then
local root = model.rootScale
if not (root and root > 0) then root = 1 end
local h = (model.height or 0) / root
if h > 0 then
local px, py, pz, worst = nil, nil, nil, 0
for f = 0, rec.frames - 1 do
self:pose(anim, f, true)
local x, y, z = centre(self, model.boneCount)
if x and px then
local d = (((x - px) ^ 2 + (y - py) ^ 2 + (z - pz) ^ 2) ^ 0.5) / h
if d > worst then worst = d end
end
px, py, pz = x, y, z
end
if worst > StadiumRig.ANCHOR_STEADY then
model.anchorOk = false
V.mod.log:info("stadium: species %s moves its own body %.1f "
.. "body-heights in one frame of its standby loop -- "
.. "not anchoring it, the measurement cannot be "
.. "trusted", tostring(model.species), worst)
end
end
end
-- and leave the bind pose behind, not the last frame of the idle
self:pose(nil, 0, false)
end
-- ------- and why the offset is SMOOTHED
--
-- A better centre is not enough on its own. Any estimate that follows the
-- pose carries the pose's own frame-to-frame wobble into it, and the anchor
-- multiplies that up into a translation of the whole Pokemon -- so a species
-- whose source data is erratic (Pidgeot's standby loop has a few frames of
-- junk in it, and no estimator can smooth data that is genuinely wrong)
-- would shake bodily rather than in the one bone that is wrong.
--
-- So the offset is low-passed. What the anchor is FOR is a slow excursion --
-- a Pokemon swimming seven body-heights away over two seconds -- and that
-- survives a filter with this time constant untouched, while anything
-- oscillating frame to frame is flattened. The correction ends up describing
-- where the Pokemon has drifted TO, never how it is shaking on the way.
--
-- HALF_LIFE is in seconds: the time the offset takes to close half of any
-- gap between where it is and where the pose says it should be. Short enough
-- that a real excursion is caught within a few frames of starting, long
-- enough that a 30 Hz wobble does not survive it.
StadiumRig.ANCHOR_HALF_LIFE = 0.05
-- ------- what this does NOT fix, and why it stops here
--
-- The filter is a proportion, so it divides the input wobble down rather than
-- bounding it -- and one species' data is bad enough to get through anyway.
-- Pidgeot's standby loop carries a few frames of junk rotation (the worst in
-- the set, and a known issue since before the anchor existed), which moves
-- the body estimate three body-heights inside a single frame; filtered, that
-- is still about three pixels a frame on a fourteen-pixel model.
--
-- Two further mechanisms were built and MEASURED against the set, and both
-- were taken back out:
--
-- a rate limit on the correction bounded the shake to a third of a pixel,
-- and cost so much tracking that 33 of the 148 entrances went back to
-- leaving the frame -- half the problem the anchor exists to solve
--
-- a rate limit on the MEASUREMENT, to tell a spike from an excursion by
-- speed, could not separate them: the fastest real excursion (Dewgong's
-- entrance, five and a half body-heights a second) is close enough to
-- Pidgeot's sustained junk that any threshold either clipped Dewgong or
-- passed Pidgeot, and freezing on distrust made both worse
--
-- So it stops here, at the setting that is right for the 147 species whose
-- data is not broken. Pidgeot is a data problem and belongs with the other
-- data problems in the CHANGELOG's Known section, not in this control loop:
-- the alternative was distorting every other Pokemon's animation to flatter
-- one whose source frames are wrong.
-- Pull the pose back toward the tile. `limit` is in the Pokemon's own
-- body-heights; nil or a non-positive value leaves the pose exactly as posed.
-- `dt` is the frame's own delta; without one the offset is applied whole,
-- which is what a still (the QA sweep, a probe) wants.
function StadiumRig:anchor(limit, dt)
if not (limit and limit > 0) then return end
local model = self.model
local n = model.boneCount
-- the vertices are in RAW units, before the model_root scale that
-- model.height is measured after
local root = model.rootScale
if not (root and root > 0) then root = 1 end
local h = (model.height or 0) / root
if not (h > 0) then return end
local bx, by, bz = model.bindCX, model.bindCY, model.bindCZ
if not bx then return end -- never measured; leave the pose alone
if model.anchorOk == false then return end -- and unmeasurable, at that
local x, y, z = centre(self, n)
if not x then return end
local dx, dy, dz = x - bx, y - by, z - bz
local dist = (dx * dx + dy * dy + dz * dz) ^ 0.5
local allow = limit * h
-- what the pose alone asks for: the EXCESS beyond the limit, so what is
-- inside it stays and the motion keeps its shape
local ox, oy, oz = 0, 0, 0
if dist > allow and dist > 0 then
local k = (dist - allow) / dist
ox, oy, oz = dx * k, dy * k, dz * k
end
-- and then toward it rather than straight to it (see ANCHOR_HALF_LIFE),
-- and never faster than ANCHOR_RATE
if dt and dt > 0 then
local half = StadiumRig.ANCHOR_HALF_LIFE
local a = (half > 0) and (1 - 0.5 ^ (dt / half)) or 1
if a > 1 then a = 1 end
local px, py, pz = self.anchorX or ox, self.anchorY or oy, self.anchorZ or oz
ox = px + (ox - px) * a
oy = py + (oy - py) * a
oz = pz + (oz - pz) * a
end
self.anchorX, self.anchorY, self.anchorZ = ox, oy, oz
if ox == 0 and oy == 0 and oz == 0 then return end
local pivot, drw = self.pivotM, self.drawM
for b = 1, n do
local o = (b - 1) * 12
pivot[o + 4] = pivot[o + 4] - ox
pivot[o + 8] = pivot[o + 8] - oy
pivot[o + 12] = pivot[o + 12] - oz
drw[o + 4] = drw[o + 4] - ox
drw[o + 8] = drw[o + 8] - oy
drw[o + 12] = drw[o + 12] - oz
end
end
-- ------- the skin
--
-- Every vertex through its one bone's draw matrix, and its normal through
-- the same bone's pivot (a pure rotation, so the normal survives a
-- non-uniformly scaled bone -- which several species have).
--
-- `yaw` is the model matrix's own turn, and it is folded in HERE rather
-- than left to the matrix because the shade has to be computed against the
-- WORLD normal: a Pokemon turned to face its opponent has a differently lit
-- flank than one facing the camera, and the sun does not turn with it.
function StadiumRig:skin(yaw)
local cy, sy = cos(yaw or 0), sin(yaw or 0)
local drw, piv = self.drawM, self.pivotM
for _, part in ipairs(self.parts) do
local prim, rows = part.prim, part.rows
local px, py, pz = prim.px, prim.py, prim.pz
local nx, ny, nz = prim.nx, prim.ny, prim.nz
local bone = prim.bone
for k = 1, prim.vertCount do
local o = (bone[k] - 1) * 12
local x, y, z = px[k], py[k], pz[k]
local row = rows[k]
row[1] = drw[o + 1] * x + drw[o + 2] * y + drw[o + 3] * z + drw[o + 4]
row[2] = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z + drw[o + 8]
row[3] = drw[o + 9] * x + drw[o + 10] * y + drw[o + 11] * z + drw[o + 12]
local ax, ay, az = nx[k], ny[k], nz[k]
local wx = piv[o + 1] * ax + piv[o + 2] * ay + piv[o + 3] * az
local wy = piv[o + 5] * ax + piv[o + 6] * ay + piv[o + 7] * az
local wz = piv[o + 9] * ax + piv[o + 10] * ay + piv[o + 11] * az
-- the model matrix's yaw, by hand: (x, z) turned, y untouched
row[6] = SHADE_BASE + SHADE_X * (cy * wx + sy * wz) + SHADE_Y * wy
+ SHADE_Z * (cy * wz - sy * wx)
end
pcall(part.mesh.setVertices, part.mesh, rows)
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
-- channel index has its texture REPLACED every frame from a stream of
-- texture-table indices (src/18140.c func_800176DC) -- which is how every
-- Pokemon in the game blinks, and how a confused one gets swirls. glTF has
-- no channel for that, so the .glb files carry only the first frame; the
-- pack carries the streams.
--
-- `aux` is an index into model.auxAnims (the stream set) and `frame` its
-- own frame counter, which runs independently of the skeletal one.
-- The eyes, and everything else a material swaps per frame.
--
-- Sampled at the SKELETAL animation's own frame -- the one pose() just
-- resolved -- and CLAMPED past the end of the stream rather than wrapped.
-- Both halves of that matter, and getting either wrong is visible.
--
-- The frame is the skeleton's because in the game a single counter drives
-- both; the data says so plainly, since 507 of the 691 paired animations in
-- the set have a texture animation exactly as long as the skeletal one it
-- rides with.
--
-- The clamp is what the game's own sampler does (func_80017540 indexes the
-- stream and holds the last entry past its end), and it is the whole
-- difference between a blink and a twitch. Rattata's standby loop is forty
-- frames and its blink is FIVE -- `6 8 7 8 6`, open through closed and back.
-- Wrapped on the blink's own length that plays six times a second, which is
-- what it looked like. Clamped, the eye blinks once at the top of the loop
-- and stays open for the remaining thirty-five frames, so it blinks about
-- once a second and a half.
function StadiumRig:textures(aux)
local model = self.model
local anim = aux and model.auxAnims and model.auxAnims[aux] or nil
local frame = self.frameAt or 0
for _, part in ipairs(self.parts) do
local prim = part.prim
local index = prim.tex
if anim and prim.texAnim and prim.texAnim >= 0 and prim.texMap then
local stream = anim.channels[prim.texAnim + 1]
local n = stream and #stream or 0
if n > 0 then
local at = frame + 1
if at > n then at = n end
if at < 1 then at = 1 end
local mapped = prim.texMap[stream[at]]
if mapped then index = mapped end
end
end
part.texture = StadiumPack.image(model, index)
end
end
-- ------- the draw
--
-- `model` here is the MODEL MATRIX -- where this Pokemon stands, how big
-- and which way round -- and `sunModel` the transform the shadow pass drew
-- it with, which for these is the same matrix (unlike a character's leaning
-- card; see Voxel3D.draw).
--
-- Seams off for the whole of it: the voxel wireframe draws the integer
-- planes of a mesh's own model space, and these vertices are in the N64's
-- own units where an integer plane means nothing (see VoxelGrid). Glass off
-- for the same reason the sprite passes turn it off -- the mask's
-- coordinates belong to the tileset atlas, not to a Pokemon's texture.
function StadiumRig:draw(matrix, pull)
Voxel3D.seams(false)
Voxel3D.glass(false)
local additive = nil
for _, part in ipairs(self.parts) do
if part.prim.additive then
-- held back to a second pass so the flames composite over the body
-- rather than depth-fighting it
additive = additive or {}
additive[#additive + 1] = part
elseif part.texture then
Voxel3D.draw(part.mesh, part.texture, matrix, pull)
end
end
if additive then
Voxel3D.blend("add")
for _, part in ipairs(additive) do
if part.texture then
Voxel3D.draw(part.mesh, part.texture, matrix, pull)
end
end
Voxel3D.blend(nil)
end
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- The same geometry as the SUN sees it: no camera-ward pull (a trick for
-- the view's own depth buffer, which would drag a shadow off its owner) and
-- through the shadow pass's own draw call. The generated flame prims are
-- skipped -- a fire casts light, not a shadow.
function StadiumRig:caster(shadowMap, matrix)
for _, part in ipairs(self.parts) do
if part.texture and not part.prim.additive then
shadowMap.draw(part.mesh, part.texture, matrix)
end
end
end
return StadiumRig
+314
View File
@@ -0,0 +1,314 @@
-- STADIUM battles: getting at the Pokemon Stadium ROM.
--
-- Byte order, the archive the battle models are packed into, the Yay0
-- decompressor that unwraps each one, and the per-species battle tables. It
-- is a port of model_extract/pipeline/rom.py, function for function, and the
-- Python remains the reference: tools/stadium_pack.py drives that side and
-- tests/stadium_extract_test.lua diffs this side's finished packs against it
-- byte for byte.
--
-- ------- why this exists in Lua at all
--
-- The mod cannot ship the models. They are ROM data, so what ships is the
-- READER, and the player supplies the ROM -- exactly the arrangement the
-- engine itself already has for the Game Boy ROM it is a recompilation of
-- (src/import/RomImporter.lua). Everything from `baserom.z64` to
-- `assets/stadium/NNN.dsm` therefore has to happen here, on the machine, in
-- Lua, with no Python and no build step.
--
-- ------- what makes that tractable
--
-- Three steps, and none of them needs a decompilation toolchain:
--
-- 1. BYTE ORDER. The three N64 dump conventions differ by a swap that is
-- detected from the magic word and undone once, on load.
-- 2. THE ARCHIVE. The segment at 0x920000 is a count and a table of
-- (offset, size) records. No compression at that level, no names.
-- 3. Yay0. Nintendo's LZ variant: a bitstream where a 1 copies a literal
-- byte and a 0 pulls a (distance, length) pair out of a side table.
-- Thirty lines, and the same thirty lines the Python has.
--
-- Verified in the Python by decompressing all 215 entries and diffing against
-- what the decompilation's own `make init` produces: 215/215 identical.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumRom = {}
local byte = string.byte
local char = string.char
local concat = table.concat
local sub = string.sub
local floor = math.floor
-- ROM offsets, from pokestadium-us.yaml by way of pipeline/rom.py.
StadiumRom.POKEMON_MODELS = 0x920000 -- archive of the 215 battle models
StadiumRom.BATTLE_DATA = 0x70D3A0 -- per-species battle tables
StadiumRom.MAIN_ROM = 0x1000 -- main code segment ...
StadiumRom.MAIN_VRAM = 0x80000400 -- ... and where it lands in RAM
StadiumRom.PTR_TABLE_VRAM = 0x80075BD0 -- D_80075BD0[species - 1]
-- The revision every offset above is keyed to. A different ROM still runs --
-- it may well be a regional variant with the same layout -- but the caller is
-- told, because "the models came out as garbage" and "that is not the ROM
-- this was written against" are the same fact and only one of them is useful.
StadiumRom.US_MD5 = "ed1378bc12115f71209a77844965ba50"
-- The battle table's shape: 0xB90 bytes a species, as 0x10-byte entries.
-- Entries 0..164 are the moves (entry n drives move n + 1) and 165 up are the
-- fixed battle contexts.
StadiumRom.STRIDE = 0xB90
StadiumRom.ENTRY = 0x10
StadiumRom.N_MOVES = 165
-- How many of the archive's 215 models are the battle Pokemon. The rest are
-- props and trophies with no battle table.
StadiumRom.N_POKEMON = 151
-- ------- byte order
--
-- .z64 is big-endian and native; .v64 has each pair of bytes swapped; .n64
-- has each word reversed. `gsub` with a capture-reversing replacement does
-- either in one call through C rather than a Lua loop over 33 million bytes.
local MAGIC_Z64 = "\128\055\018\064"
local MAGIC_V64 = "\055\128\064\018"
local MAGIC_N64 = "\064\018\055\128"
-- Normalise a dump to .z64 order, or nil when it is not an N64 ROM at all.
function StadiumRom.normalise(bytes)
if type(bytes) ~= "string" or #bytes < 0x1000 then return nil end
local magic = sub(bytes, 1, 4)
if magic == MAGIC_Z64 then return bytes end
if magic == MAGIC_V64 then return (bytes:gsub("(.)(.)", "%2%1")) end
if magic == MAGIC_N64 then
return (bytes:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
return nil
end
-- ------- Yay0
--
-- The output has to be RANDOM ACCESS while it is being written -- a back
-- reference copies from what has already been produced, and overlapping runs
-- are legal and common -- so it is built in a flat table of byte values and
-- turned into a string at the end.
--
-- The string.char conversion is the part that wants care: it is variadic and
-- has an argument limit, so the table is walked in blocks and the blocks
-- concatenated. Blocks of 4096 keep the call count and the intermediate
-- string count both low; the whole 151-model set converts in well under a
-- second on LuaJIT, which is what made an FFI buffer unnecessary here and
-- kept this module portable to any Lua the engine runs on.
local CHUNK = 4096
-- LuaJIT keeps `unpack` global; 5.2+ moved it onto table.
local unpack = unpack or table.unpack
local function bytesToString(out, n)
if n == 0 then return "" end
local parts, np = {}, 0
local i = 1
while i <= n do
local j = i + CHUNK - 1
if j > n then j = n end
np = np + 1
parts[np] = char(unpack(out, i, j))
i = j + 1
end
return concat(parts)
end
-- Nintendo Yay0. Header: magic, decompressed size, link table offset, chunk
-- offset; then a bitstream read a word at a time.
function StadiumRom.yay0(src, base)
base = base or 0
if sub(src, base + 1, base + 4) ~= "Yay0" then return nil, "not Yay0" end
local function be32(o)
local a, b, c, d = byte(src, base + o + 1, base + o + 4)
return ((a * 256 + b) * 256 + c) * 256 + d
end
local size = be32(4)
-- all three cursors are 1-based indices into `src`; the mask stream starts
-- immediately after the 16-byte header
local maskP = base + 0x10 + 1
local linkP = base + be32(8) + 1
local chunkP = base + be32(12) + 1
local out = {}
local pos = 0 -- bytes produced so far
local mask, bits = 0, 0
while pos < size do
if bits == 0 then
local a, b, c, d = byte(src, maskP, maskP + 3)
mask = ((a * 256 + b) * 256 + c) * 256 + d
maskP = maskP + 4
bits = 32
end
if mask >= 0x80000000 then
pos = pos + 1
out[pos] = byte(src, chunkP)
chunkP = chunkP + 1
else
local a, b = byte(src, linkP, linkP + 1)
linkP = linkP + 2
local link = a * 256 + b
local dist = link % 0x1000
local count = floor(link / 0x1000)
if count == 0 then
count = byte(src, chunkP) + 0x12
chunkP = chunkP + 1
else
count = count + 2
end
-- overlapping runs are legal: copying one byte at a time from the
-- output as it grows is the behaviour, not a naive version of it
local copy = pos - dist
for _ = 1, count do
pos = pos + 1
out[pos] = out[copy]
copy = copy + 1
end
end
mask = (mask * 2) % 0x100000000
bits = bits - 1
end
return bytesToString(out, size)
end
-- Unwrap whatever container an asset arrived in. The model archive's entries
-- are PERS-SZP: an eight-byte magic plus a header size, wrapping a Yay0
-- stream.
function StadiumRom.decompress(blob)
if sub(blob, 1, 8) == "PERS-SZP" then
local a, b, c, d = byte(blob, 9, 12)
local header = ((a * 256 + b) * 256 + c) * 256 + d
return StadiumRom.yay0(blob, header)
end
if sub(blob, 1, 4) == "Yay0" then return StadiumRom.yay0(blob, 0) end
return blob
end
-- ------- the ROM
local Rom = {}
Rom.__index = Rom
-- `bytes` is the whole file. Returns the ROM, or nil plus why.
function StadiumRom.open(bytes)
local data = StadiumRom.normalise(bytes)
if not data then return nil, "not an N64 ROM (bad magic)" end
return setmetatable({ data = data }, Rom)
end
function Rom:u8(o)
return byte(self.data, o + 1)
end
function Rom:u32(o)
local a, b, c, d = byte(self.data, o + 1, o + 4)
if not d then return 0 end
return ((a * 256 + b) * 256 + c) * 256 + d
end
function Rom:vramToRom(vram)
return StadiumRom.MAIN_ROM + (vram - StadiumRom.MAIN_VRAM)
end
-- The md5 of the normalised image, or nil where LOVE's hash is not there
-- (the headless suite). Only ever used to tell the player which ROM they
-- gave us, never to refuse one.
function Rom:md5()
if self.hash ~= nil then return self.hash or nil end
local ok, hex = pcall(function()
local digest = love.data.hash("md5", self.data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end)
self.hash = (ok and hex) or false
return self.hash or nil
end
function Rom:isExpectedUS()
local hex = self:md5()
return hex == nil or hex == StadiumRom.US_MD5
end
-- ------- the archive
--
-- Segments that hold many files start with
-- u32 tag, u32 0, u32 totalSize, u32 fileCount
-- followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, all
-- relative to the start of the segment.
--
-- Only the top three bytes of the first word are reliably zero: the model
-- archive puts a nonzero value in the low byte, which is the same quirk the
-- decompilation's own tools/unpack_asset.py works around.
--
-- Returns a list of { start, size } rather than the bytes, so nothing is
-- copied until a caller actually wants a file.
function Rom:archive(off)
local tag = self:u32(off)
if (tag - tag % 256) ~= 0 or self:u32(off + 4) ~= 0 then return nil end
local count = self:u32(off + 12)
if count <= 0 or count >= 4096 then return nil end
local out = {}
for i = 0, count - 1 do
local rec = off + 0x10 + i * 0x10
out[i + 1] = { start = off + self:u32(rec), size = self:u32(rec + 4) }
end
return out
end
-- The entries of the battle-model archive, uncopied.
function Rom:models()
if not self.modelDir then
self.modelDir = self:archive(StadiumRom.POKEMON_MODELS) or {}
end
return self.modelDir
end
function Rom:modelCount()
return #self:models()
end
-- One model fragment, decompressed. `fileno` is 0-based, as in the Python and
-- in the source-file names: `N.bin` holds species N + 1.
function Rom:model(fileno)
local rec = self:models()[fileno + 1]
if not rec then return nil end
return StadiumRom.decompress(sub(self.data, rec.start + 1,
rec.start + rec.size))
end
-- ------- the per-species battle tables
--
-- func_84302658 in src/fragments/62 DMAs 0xB90 bytes a species out of the
-- 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. Byte 0 of
-- each 0x10-byte entry indexes that Pokemon's animation list and byte 1 its
-- auxiliary (texture) animation list.
--
-- Returns a 0-based array-like table of { anim, aux }, aux 0xFF meaning none
-- and coming back as -1 -- the shape the packer writes.
function Rom:battleRows(species)
local ptrTable = self:vramToRom(StadiumRom.PTR_TABLE_VRAM)
local raw = self:u32(ptrTable + (species - 1) * 4)
local o = StadiumRom.BATTLE_DATA + raw % 0x1000000
local rows = {}
local n = StadiumRom.STRIDE / StadiumRom.ENTRY
for e = 0, n - 1 do
local anim = self:u8(o + e * StadiumRom.ENTRY)
local aux = self:u8(o + e * StadiumRom.ENTRY + 1)
rows[e] = { anim, aux == 0xFF and -1 or aux }
end
rows.n = n
return rows
end
return StadiumRom
+309
View File
@@ -0,0 +1,309 @@
-- STADIUM battles: importing the ROM, instead of being told where to put it.
--
-- The mod ships no Pokemon Stadium models and cannot -- they are that game's
-- data -- so the player supplies the cartridge. The original instruction for
-- that was "make a folder called baseroms next to the game and drop the file
-- in it", which is a fine sentence to write and a poor thing to ask. It needs
-- a folder the player has to create, in a place that is different on every
-- platform and is inside an unwritable archive on a packaged build, and it
-- fails SILENTLY: the two STADIUM rungs are simply not on the row, and
-- nothing on screen says why.
--
-- So this opens a file picker instead, from a row on the OPTIONS menu, and
-- the folder keeps working for anyone who prefers it (StadiumInstall).
--
-- ------- the picker is the host's, not LOVE's
--
-- LOVE 11.5 has no file dialog. love.window.showFileDialog arrived in 12 and
-- love.system.pickFile is a native bridge this project ships for mobile
-- rather than part of LOVE at all. What every desktop OS does have is a
-- dialog reachable from a shell, so that is what is used here -- osascript on
-- macOS, PowerShell's OpenFileDialog on Windows, zenity then kdialog on
-- Linux.
--
-- This is deliberately the SAME four commands the engine's own ROM importer
-- uses for the Game Boy cartridge (src/import/RomImporter.lua's chooseRom),
-- down to writing the Windows pick as UTF-8 -- the console's OEM codepage
-- mangles a non-ASCII path into something that crashes the next text draw.
-- Being a second copy of that is worth it: a mod cannot call into the
-- importer's private helpers, and the alternative is asking the engine to
-- grow a seam for one caller.
--
-- The dialog BLOCKS. io.popen waits for the player to choose, and the game is
-- frozen for as long as it is up. That is what the engine's importer does
-- too, it is what a modal dialog means, and the frame it freezes on is an
-- options menu.
--
-- ------- and the ROM is not kept
--
-- The picked file is read, built from, and forgotten -- nothing is copied
-- anywhere. A Stadium cartridge is 32 MB and the models built out of it are
-- 34, so keeping both would double the cost of a feature for a file that has
-- no further use: the packs are what the game reads afterwards, and the
-- marker records the ROM's md5 so a swapped cartridge is still noticed.
--
-- The one thing that costs is a format bump, which invalidates the packs and
-- leaves nothing to rebuild from. That is what the row still being there is
-- for -- it reads READY, and pressing it imports again.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumInstall = V.require("StadiumInstall")
local StadiumRomPick = {}
StadiumRomPick.LABEL = "STADIUM ROM"
StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom"
-- Names the REVISION, because that is the thing a player gets wrong: the
-- model offsets are keyed to US 1.0 and nothing else is going to work.
local PROMPT = "Choose your Pokemon Stadium (US) 1.0 ROM"
-- ------- the host, at arm's length
--
-- Everything below is read through pcall and a presence test. The mod loader
-- hands a mod the real `io` and `os` today, but a mod that TAKES that for
-- granted is one that stops loading the day a sandbox arrives -- and this is
-- a convenience on top of a folder scan that works without any of it.
local function haveShell()
local ok, popen = pcall(function() return io and io.popen end)
return (ok and popen) and true or false
end
local function haveFiles()
local ok, open = pcall(function() return io and io.open end)
return (ok and open) and true or false
end
local function osName()
local ok, name = pcall(function() return love.system.getOS() end)
return ok and name or nil
end
-- Run a command and return its trimmed stdout, or nil for anything that did
-- not produce a line -- a cancelled dialog, a missing zenity, a shell that
-- is not there.
local function commandOutput(cmd)
if not haveShell() then return nil end
local ok, pipe = pcall(io.popen, cmd)
if not (ok and pipe) then return nil end
local okRead, out = pcall(pipe.read, pipe, "*a")
pcall(pipe.close, pipe)
if not (okRead and type(out) == "string") then return nil end
out = out:gsub("^%s+", ""):gsub("%s+$", "")
return (out ~= "") and out or nil
end
-- ------- can this machine open a DIALOG
--
-- Desktop only, and honestly so.
--
-- On ANDROID the picker is a native bridge (love.system.pickFile) whose
-- kind -> filename mapping is a fixed list of three in the engine's own C++,
-- and an unrecognised kind falls through to `picked_rom.gb`. That is not
-- merely the wrong name -- it is the file the engine's Game Boy importer is
-- watching, and reading that code settles it: the importer's size test only
-- SKIPS a 1 MB file it has already imported, so a 32 MB N64 ROM landing
-- there falls straight through to `love.filesystem.remove` and
-- `startData` -- deleted, and then reported to the player as a broken Game
-- Boy ROM. So the bridge is not called until it learns the kind, which is a
-- two-line change in System.cpp and an APK rebuild (see README).
--
-- Android is not stuck without it: conf.lua points the save directory at the
-- app's external-files folder, so `baseroms/` there is reachable over USB or
-- any file manager with no root and no permission prompt. What Android
-- lacked was being TOLD that -- the row vanished, and the folder's absolute
-- path was only ever written to a console no phone shows. That is what the
-- note below is for.
function StadiumRomPick.canDialog()
if not (haveShell() and haveFiles()) then return false end
local p = osName()
return p == "Windows" or p == "OS X" or p == "Linux"
end
-- Kept as the old name for callers that only wanted "is there a dialog".
StadiumRomPick.available = StadiumRomPick.canDialog
-- Where a SAF pick would land if the native bridge grows a Stadium kind.
-- Watched unconditionally (see poll): on a build that never writes it this
-- costs one getInfo a frame, and on one that does the mod needs no further
-- change to use it.
StadiumRomPick.PICKED = "picked_stadium.z64"
-- Open the dialog. Returns the chosen absolute path, or nil when the player
-- cancelled or no dialog could be opened.
function StadiumRomPick.choose()
local p = osName()
if p == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type ]]
.. [[{"z64", "n64", "v64"})' 2>/dev/null]]):format(PROMPT))
elseif p == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. PROMPT .. "';",
"$d.Filter='Nintendo 64 ROM (*.z64;*.n64;*.v64)|*.z64;*.n64;*.v64"
.. "|All files (*.*)|*.*';",
-- as UTF-8: the console's OEM codepage would mangle a non-ASCII path
-- and crash the next text draw that showed it
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding="
.. "[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif p == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" ]]
.. [[--file-filter="Nintendo 64 ROM | *.z64 *.n64 *.v64" 2>/dev/null]])
:format(PROMPT))
if path then return path end
-- zenity is absent on plenty of installs (and on most handheld Linux
-- distributions); KDE's own dialog is the usual second answer
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.z64 *.n64 *.v64|]]
.. [[Nintendo 64 ROM" 2>/dev/null]])
end
return nil
end
-- Read an ABSOLUTE path, which love.filesystem cannot: it only sees inside
-- the physfs mount, and a picked file is anywhere on the disk. Returns the
-- bytes, or nil plus a reason short enough to fit the loading screen.
function StadiumRomPick.read(path)
if not haveFiles() then return nil, "no file access" end
local ok, fp = pcall(io.open, path, "rb")
if not (ok and fp) then return nil, "could not open that file" end
local okRead, bytes = pcall(fp.read, fp, "*a")
pcall(fp.close, fp)
if not (okRead and type(bytes) == "string" and #bytes > 0) then
return nil, "could not read that file"
end
return bytes
end
-- ------- the whole flow, from one keypress
--
-- Pick, read, start the build, and put the loading screen up over whatever
-- asked -- which is the OPTIONS menu, so the row is there again underneath
-- when the build finishes and now reads READY.
--
-- A CANCELLED dialog is not a failure and says nothing: the player opened a
-- file browser and changed their mind, and a mod that made an announcement
-- about that would be the second most annoying thing on the menu.
--
-- Everything else lands on the loading screen's own failure state, because it
-- is the one surface in this mode with room for a sentence -- and because a
-- player who has just chosen the wrong file is owed a reason and not a row
-- that quietly goes on saying IMPORT.
function StadiumRomPick.import(game)
if StadiumInstall.status.state == "building" then return false end
local StadiumScreen = V.require("StadiumScreen")
-- No dialog on this platform: say where the file goes, on screen, because
-- that is the whole of what the player is missing and the console is not
-- somewhere they can read it.
if not StadiumRomPick.canDialog() then
if game and game.stack then
game.stack:push(StadiumScreen.newNote(game, "STADIUM ROM",
"PUT STADIUM US 1.0 HERE:",
StadiumInstall.romHintFile()))
end
return false
end
local path = StadiumRomPick.choose()
if not path then return false end
local function fail(why)
StadiumInstall.status.state = "failed"
StadiumInstall.status.error = why
if game and game.stack then
game.stack:push(StadiumScreen.new(game, true))
end
return false
end
local bytes, err = StadiumRomPick.read(path)
if not bytes then return fail(err or "could not read that file") end
local ok, beginErr = StadiumInstall.beginFrom(bytes, path)
if not ok then return fail(tostring(beginErr)) end
if game and game.stack then
game.stack:push(StadiumScreen.new(game, true))
end
return true
end
-- ------- the row
--
-- An ACTION rather than a value, which is why it is not a ModSetting: there
-- is no rung to store, nothing for the mod manager's page to persist, and
-- nothing to restore on the next boot. What it shows is a STATE -- the models
-- are there or they are not -- and what it does is the only thing it can do.
--
-- Still offered once they ARE there, reading READY. Pressing it imports
-- again, which is how a player swaps to a different revision, and how they
-- rebuild after a format bump has invalidated the packs and left nothing on
-- disk to rebuild from (see the header: the ROM is not kept).
--
-- nil where no dialog can be opened, which takes the row off the menu
-- entirely rather than offering a button that cannot do anything.
function StadiumRomPick.row()
return {
id = StadiumRomPick.ID,
label = StadiumRomPick.LABEL,
value = function()
if StadiumInstall.status.state == "building" then return "BUILDING" end
if StadiumInstall.available() then return "READY" end
-- WHERE, not IMPORT, where pressing it can only tell you the folder:
-- a row that says IMPORT and then does not import is a worse row than
-- one that says what it actually does
return StadiumRomPick.canDialog() and "IMPORT" or "WHERE?"
end,
step = function(game)
pcall(StadiumRomPick.import, game)
return true
end,
}
end
-- ------- a pick that landed while we were not looking
--
-- The desktop dialog BLOCKS, so `import` above can read the answer on the
-- next line. A SAF pick cannot work that way: it is a separate activity,
-- Android is free to destroy the game while it is up, and the file appears
-- some frames later -- so the only way to notice one is to look for it.
--
-- Nothing writes this filename today (see canDialog). It is watched anyway so
-- that teaching the native bridge one more kind is the whole of the Android
-- picker work, with no second change needed here.
--
-- Consumed and DELETED either way: a 32 MB file left in the save directory
-- would be imported again on the next boot, and kept forever if the import
-- failed.
function StadiumRomPick.poll(game)
local f = love and love.filesystem
if not (f and f.getInfo) then return false end
if StadiumInstall.status.state == "building" then return false end
local ok, info = pcall(f.getInfo, StadiumRomPick.PICKED, "file")
if not (ok and info) then return false end
local okRead, bytes = pcall(f.read, StadiumRomPick.PICKED)
pcall(f.remove, StadiumRomPick.PICKED)
if not (okRead and type(bytes) == "string") then return false end
local StadiumScreen = V.require("StadiumScreen")
local started, err = StadiumInstall.beginFrom(bytes, StadiumRomPick.PICKED)
if not started then
StadiumInstall.status.state = "failed"
StadiumInstall.status.error = tostring(err)
end
if game and game.stack then
game.stack:push(StadiumScreen.new(game, true))
end
return true
end
return StadiumRomPick
+391
View File
@@ -0,0 +1,391 @@
-- STADIUM battles: the one-time build, on screen.
--
-- A pushed game state, so it draws in the Game Boy's own 160x144 and stops
-- everything under it -- which is what it should do, because it is doing real
-- work and the player should not be walking around while it happens.
--
-- ------- why a species a frame
--
-- A species takes roughly fifty milliseconds to extract, and there are 151 of
-- them. That is ten seconds, which has to go somewhere. Doing them one per
-- frame puts the whole cost on this screen where it is explained, keeps the
-- bar moving at a visible rate, and leaves the frame free to draw between
-- them. Batching more per frame would finish no sooner -- the work is the
-- same -- and would only make the bar jump.
--
-- ------- what it says
--
-- Three things, and each of them is answering a question the player would
-- otherwise have to guess at while the game sits there:
--
-- WHAT is happening -- "STADIUM EXTRACTION", which is what it is.
--
-- HOW FAR through it is -- a bar, filled by species written rather than by
-- elapsed time, so it cannot lie about the remaining work.
--
-- THAT IT IS ALIVE -- which Pokemon it is on, by name. Not decoration: it
-- is the difference between a progress bar the player trusts and one they
-- suspect has hung, and it costs one lookup a frame.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local StadiumInstall = V.require("StadiumInstall")
local StadiumScreen = {}
StadiumScreen.__index = StadiumScreen
-- The Game Boy frame this draws in.
local W, H = 160, 144
-- How long the finished message stays up before the screen retires itself.
StadiumScreen.HOLD = 1.1
local Font = nil
local function font()
if Font then return Font end
local ok, F = pcall(require, "src.render.Font")
if ok then Font = F end
return Font
end
-- Black glyphs, because that is the only colour the Game Boy font sheets
-- have -- they are black on transparent, so setColor cannot lighten one.
-- Everything here is therefore laid out dark-on-light.
local function text(str, x, y)
local F = font()
if not F then return end
love.graphics.setColor(0, 0, 0, 1)
F.draw(str, math.floor(x), math.floor(y))
end
local function centred(str, y)
local F = font()
if not F then return end
text(str, (W - F.width(str)) / 2, y)
end
-- How many glyphs fit across the frame. The font is a fixed eight pixels, so
-- twenty is the line -- and a centred string longer than that does not
-- overflow tidily off one side, it clips off BOTH and loses its first word as
-- well as its last ("that is not a Pokemon Stadium ROM" came out as "at is
-- not a Pokemon").
--
-- Fixed, and it stays fixed: shrinking the text to fit more in was tried and
-- the font will not take it. These are 1-bit 8x8 bitmaps, so a fractional
-- downscale drops whole pixel rows out of every glyph -- at 0.75 the last
-- line of an Android save path came out as mush. Long strings get more LINES
-- instead (see the note layout in draw).
local COLS = 20
-- Break a string into lines that fit, on word boundaries, and never more than
-- `limit` of them.
local function wrapped(str, limit, cols)
limit = limit or 2
cols = cols or COLS
local lines, line = {}, nil
local function push(text)
if #lines < limit then lines[#lines + 1] = text end
end
for word in tostring(str):gmatch("%S+") do
local try = line and (line .. " " .. word) or word
if #try <= cols then
line = try
else
if line then push(line) end
-- A word longer than the line is BROKEN ACROSS lines rather than cut.
-- It is always a path, and a path is the one thing here that has to be
-- readable in full -- an absolute Android save directory runs to
-- ninety-odd characters with no spaces in it at all, so truncating at
-- twenty told the player almost nothing.
while #word > cols do
push(word:sub(1, cols))
word = word:sub(cols + 1)
end
line = word
end
if #lines >= limit then break end
end
if line then push(line) end
return lines
end
-- ------- dex number -> the engine's own species key
--
-- Built once, from the loaded data rather than from a list of names carried
-- here: a list would be a second place for the same 151 facts to live, and
-- would go stale against a mod that renames one.
local dexNames = nil
local function speciesName(dex)
if not dex then return nil end
if not dexNames then
dexNames = {}
local ok, data = pcall(function()
return require("src.core.Game").data
end)
if ok and data and data.pokemon then
for key, def in pairs(data.pokemon) do
if type(def) == "table" and def.dex then dexNames[def.dex] = key end
end
end
end
return dexNames[dex]
end
-- `adopt` means the caller has ALREADY started the build, or already decided
-- it cannot start -- which is the imported path (StadiumRomPick opens the
-- picked file itself, because love.filesystem cannot read an absolute path).
-- Without it this screen would call begin() on the way in and throw away the
-- job it was pushed to display, or overwrite the failure it was pushed to
-- explain with a fresh "no ROM in baseroms" -- which would be true, and would
-- have nothing to do with what just went wrong.
function StadiumScreen.new(game, adopt)
return setmetatable({ game = game, hold = 0, started = adopt and true or false,
adopted = adopt and true or false }, StadiumScreen)
end
-- ------- the same plate, saying something instead of doing something
--
-- A NOTE: title, a wrapped body, and a key to dismiss it. It exists because
-- the one piece of information a player on a platform with no file dialog
-- actually needs -- the absolute path of the folder to put the cartridge in
-- -- is long, machine-specific, and was only ever written to the console,
-- which nobody on a phone can read.
--
-- Same state shape and the same plate as the build screen, so there is one
-- look and one set of stack manners rather than two.
function StadiumScreen.newNote(game, title, lead, body)
return setmetatable({ game = game,
note = { title = title, lead = lead, body = body } },
StadiumScreen)
end
-- Opaque: the loading screen owns the frame, so the map underneath is not
-- drawn and not paying for a render it cannot be seen through.
StadiumScreen.isOpaque = true
function StadiumScreen:enter()
if self.note or self.adopted then return end
local ok, err = StadiumInstall.begin()
self.started = ok and true or false
if not ok then
StadiumInstall.status.state = "failed"
StadiumInstall.status.error = err
end
end
-- The buttons that dismiss a note. Every face button and START, because the
-- prompt says ANY and a player who has to hunt for the right one on a phone
-- has been lied to.
local DISMISS = { "a", "b", "start", "select" }
function StadiumScreen:update()
-- ------- a note is dismissed by a BUTTON, not by a key
--
-- `onKeyPressed` is the keyboard, and a phone has none: the touch overlay
-- feeds the engine's Input as virtual buttons (Input.overlayPressed), so a
-- state that only listens for keys cannot be closed by touch at all. That
-- stranded a player on this screen with no way off it -- the one screen in
-- the mod whose entire job is to tell somebody something and then get out
-- of the way.
--
-- Polled here rather than handled as an event because `wasPressed` is the
-- edge test the engine's own battle screens use, and it is fed by the
-- keyboard, the gamepad AND the overlay through one path.
if self.note then
local input = self.game and self.game.input
if input and input.wasPressed then
for _, btn in ipairs(DISMISS) do
if input:wasPressed(btn) then
if self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
return
end
end
end
return
end
local status = StadiumInstall.status
if status.state == "building" then
if not StadiumInstall.step() then
-- fell out of building: either finished or failed, both of which hold
-- for a moment so the player sees which
self.hold = 0
end
return
end
self.hold = self.hold + 1 / 60
-- a failure stays up longer, because it is the one the player has to read
local wait = StadiumScreen.HOLD
if status.state == "failed" then
wait = StadiumScreen.HOLD * 4
elseif status.wrongVersion then
-- a warning nobody can read is not a warning
wait = StadiumScreen.HOLD * 3
end
if self.hold >= wait then
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
end
end
-- Let the player out of a build that has gone wrong, or that they would
-- rather not wait for. Cancelling leaves the packs unbuilt, so the STADIUM
-- rungs stay off the row until the next boot offers again -- which is
-- honest, and better than a half-built set.
local function pop(self)
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
end
function StadiumScreen:onKeyPressed(key)
-- A note takes any key too. This is the KEYBOARD path and it is not the
-- one that matters on a phone -- see update, which polls the engine's
-- Input so the touch overlay's virtual buttons work as well.
if self.note then pop(self) return true end
if key == "escape" or key == "x" or key == "backspace" then
StadiumInstall.cancel()
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
return true
end
return false
end
function StadiumScreen:draw()
local status = StadiumInstall.status
love.graphics.setColor(0.93, 0.94, 0.90, 1)
love.graphics.rectangle("fill", 0, 0, W, H)
if self.note then
centred(self.note.title, 12)
-- The sentence is kept SHORT so the path can have the rest of the plate
-- at full size. Shrinking the path was tried first and does not survive
-- the font: these are 1-bit 8x8 bitmaps, so a fractional downscale drops
-- whole pixel rows out of every glyph and the last line came out as
-- mush. Nine rows of twenty characters is 180, which is longer than any
-- real save path, so nothing has to be shrunk to fit.
local lead = wrapped(self.note.lead or "", 2)
for i, line in ipairs(lead) do centred(line, 30 + (i - 1) * 10) end
local y = 30 + #lead * 10 + 6
for i, line in ipairs(wrapped(self.note.body or "", 9)) do
centred(line, y + (i - 1) * 9)
end
centred("PRESS ANY KEY", 130)
love.graphics.setColor(1, 1, 1, 1)
return
end
-- One line, and it is the whole heading: eighteen glyphs at the font's
-- fixed eight pixels is 144 of the frame's 160.
centred("STADIUM EXTRACTION", 34)
if status.state == "failed" then
centred("COULD NOT BUILD", 68)
local lines = wrapped(status.error or "unknown", 2)
for i, line in ipairs(lines) do centred(line, 82 + (i - 1) * 10) end
centred("STADIUM IS OFF", 110)
love.graphics.setColor(1, 1, 1, 1)
return
end
local done = status.done or 0
local total = status.total or StadiumInstall.COUNT
local frac = (total > 0) and (done / total) or 1
if status.state == "done" then frac = 1 end
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
-- The bar: a dark frame, an EMPTY interior the same colour as the plate,
-- and a dark fill growing left to right.
--
-- The track has to be the plate's own white rather than a light grey. This
-- draws inside the Game Boy frame, so the colorization pass quantises
-- everything here into the four GB shades and paints them -- and a grey
-- track lands one shade down, which comes out as a bar that is GREEN where
-- the work is still to do and dark where it is done. The eye reads colour
-- as the filled part and gets the progress exactly backwards.
local bx, by, bw, bh = 24, 68, W - 48, 9
love.graphics.setColor(0.06, 0.05, 0.09, 1)
love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2)
love.graphics.setColor(0.93, 0.94, 0.90, 1)
love.graphics.rectangle("fill", bx, by, bw, bh)
love.graphics.setColor(0.06, 0.05, 0.09, 1)
love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh)
if status.state == "done" then
centred("READY", 86)
-- and say so if it was built from something other than the revision every
-- offset in the reader was measured against: it may look fine, it may be
-- subtly wrong, and the player is the only one who can swap the file
if status.wrongVersion then
centred("NOT US 1.0 --", 104)
centred("MODELS MAY BE WRONG", 114)
end
else
local name = speciesName(status.species)
centred(("%d/%d"):format(done, total), 86)
if name then centred(name, 98) end
end
love.graphics.setColor(1, 1, 1, 1)
end
-- ------- when this comes up
--
-- The first frame the player is actually IN the world, rather than at boot.
-- Two reasons. The engine has its own launcher and ROM importer before the
-- game starts, and pushing over those would be fighting them for the screen.
-- And `Game.data` has to be loaded for the species names above to resolve.
--
-- Asked once. If the player cancels, or there is no ROM, this does not come
-- back until the next run -- a loading screen that reappears every time you
-- step outside would be worse than no stadium models.
local asked = false
function StadiumScreen.maybePush()
if asked then return false end
local ok, Game = pcall(require, "src.core.Game")
if not (ok and Game and Game.stack and Game.overworld) then return false end
if Game.stack:top() ~= Game.overworld then return false end
asked = true
if not StadiumInstall.pending() then
-- Say where to put a cartridge, ONCE, and only when there is nothing to
-- build from and nothing already built. The two STADIUM rungs are simply
-- absent in that case (ModSetting.setGate), which is the right thing for
-- a row to do and tells the player nothing about why -- and the answer
-- they need is an absolute path that depends on how the game was
-- installed, so it cannot be written into the options help text.
if not StadiumInstall.available() then
-- The IMPORT row is the answer wherever a file dialog can be opened,
-- and it is the better one: no folder to create, no path to get right,
-- no restart. The folder is still said, once, for the platforms with no
-- dialog (Android, a handheld Linux with neither zenity nor kdialog)
-- and for anyone who would rather drop a file than click through one.
-- The STADIUM ROM row is on the OPTIONS menu on every platform now, so
-- point at it rather than reciting a path here: where a file dialog can
-- be opened it opens one, and where it cannot it shows this same folder
-- on screen -- which is the part a phone could not otherwise find out.
local okPick, pick = pcall(V.require, "StadiumRomPick")
local label = (okPick and pick and pick.LABEL) or "STADIUM ROM"
local how = (okPick and pick and pick.canDialog())
and "opens a file picker" or "says where to put one"
V.mod.log:info("stadium: no Pokemon Stadium (US) 1.0 ROM found, so the "
.. "STADIUM battle rungs are off. OPTIONS -> %s %s; the "
.. "folder is %s", label, how, StadiumInstall.romHint())
end
return false
end
Game.stack:push(StadiumScreen.new(Game))
return true
end
-- named for the suite, which drives the screen without a boot
function StadiumScreen._reset()
asked = false
end
return StadiumScreen
+340
View File
@@ -0,0 +1,340 @@
-- The B rungs: the two discs the fight is staged on.
--
-- Where an A rung puts the fight on the MAP -- real ground, whatever the
-- route happens to look like -- a B rung puts it on two platforms against
-- the sky and draws no map at all.
--
-- ------- one stage, two rungs
--
-- The discs do not know what is standing on them. 2D-3D B stands the Game
-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium
-- models, and this file is identical for both: it draws two platforms at two
-- cells and sizes each to whatever footprint it is given. That is why the
-- flat disc rung cost a value in the 3D-BTL ladder and nothing else.
--
-- ------- why this is a rung and not a fix
--
-- Staging on the map is the better picture when the map cooperates, and it
-- often does not. Half of Kanto's interiors are furniture; a cave floor can
-- be nothing but two-cell corridors; some maps have nowhere a fight can be
-- SEEN from a low camera and are declined outright (see BattleArena), which
-- drops the player back to the flat battle screen with no warning. And even
-- where a spot exists, the ground behind the foe is a hedge or a shop counter
-- rather than anything a battle wants behind it.
--
-- Discs have none of those problems, because the stage is CARRIED rather than
-- found: it works on every map, in every building, at every step, and the
-- framing is the same every time. What it gives up is the thing STADIUM A is
-- for -- fighting somewhere real.
--
-- ------- what stays
--
-- The sky, and the light. A battle outdoors is under the hour's own sky, with
-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a
-- dressed one); a battle in a cave or a room is under that place's own void
-- and its own neutral light, exactly as the map itself would be. So the mode
-- is abstracted from the GROUND, not from the world -- walk into a cave at
-- midnight and the fight looks like a cave at midnight.
--
-- And the framing. The camera, the pins, the HUDs, the text box, the move
-- animations and the depth of field are all identical, because every one of
-- them is hung off the arena's CELLS rather than off what is under them. That
-- is the same reason STADIUM A could be an option on the mode rather than a
-- second mode, and it is why this file is a few hundred lines and not a few
-- thousand.
-- 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 StadiumStage = {}
local floor = math.floor
local sin, cos = math.sin, math.cos
local pi = math.pi
-- ------- the shape of a disc
--
-- Radius in world pixels, where a map cell is 16 and the two mons stand three
-- cells apart.
--
-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size,
-- because the set's footprints run nearly tenfold: a Caterpie is under four
-- world pixels across and Moltres, wings out, is twenty-six. One radius for
-- both is either a dinner plate under the caterpillar or a doily under the
-- bird.
--
-- RADIUS is the floor, and it is the number most species land on -- it is a
-- little over the map cell a Pokemon is sized to cover, which is the
-- proportion the Game Boy's own battle platforms have. PAD is the margin
-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from
-- being handed something the frame cannot hold.
--
-- These are the FULL radius, out to where the fade has finished; the solid
-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a
-- mon's own footprint fits inside that centre rather than out over the
-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has
-- solid ground under its edges.
StadiumStage.RADIUS = 18
StadiumStage.MAX_RADIUS = 34
StadiumStage.PAD = 1.8
-- The platform for a mon of this footprint. `r` may be nil -- nothing is
-- standing there yet, which is every frame of the send-out before the model
-- appears, and it is also the whole of the flat 2D-3D B rung, where a
-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is
-- already a little over that. Either way the platform is the plain one, and
-- it has to be there BEFORE the Pokemon lands on it.
function StadiumStage.radiusFor(r)
local want = (r or 0) * StadiumStage.PAD
if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end
if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end
return want
end
-- Per-vertex shading, in the same terms StadiumRig lights the models with, so
-- a disc and the Pokemon standing on it agree about where the sun is. Fitted
-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each
-- axis term is half the spread between that axis's two faces.
local SHADE_BASE = 0.7725
local SHADE_X = 0.06
local SHADE_Y = 0.225
local SHADE_Z = 0.11
local function shadeFor(nx, ny, nz)
local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z
if s < 0.30 then return 0.30 end
if s > 1.00 then return 1.00 end
return s
end
-- ------- the texture
--
-- The platform is a FLAT painted disc that fades out at its rim -- no rim
-- wall, no thickness, the whole thing carried in one texture on one quad
-- lying on the ground plane. That is what the Game Boy's own battle
-- platforms are, and it is what keeps the stage from competing with the
-- Pokemon standing on it.
--
-- ------- why the fade is DITHERED
--
-- The scene shader discards any texel under half alpha outright (it has to:
-- that is what keeps a sprite's transparent corners out of the depth buffer).
-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut
-- at wherever the ramp crosses 0.5, which is the one thing this must not be.
--
-- The fade is therefore an ORDERED DITHER baked into the texture's alpha:
-- every texel is fully on or fully off, and the proportion that are on falls
-- away toward the rim. It is the same trick the sky already uses for its
-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any
-- other pass, and on a mode built out of visible texels it reads as intended
-- rather than as a limitation.
--
-- The COLOUR is deliberately neutral. Everything the shader does to it after
-- this is the environment's: Voxel3D.tint carries the hour outdoors and the
-- room's own flat light indoors, and the shadow pass darkens whatever the
-- Pokemon standing on it occludes. So one texture is a sunlit platform, a
-- dusk platform and a cave platform, without a variant for each.
StadiumStage.TEX = 128
-- Where the solid centre ends, as a fraction of the disc's radius. Inside
-- this everything is opaque; from here to the rim the dither thins out.
StadiumStage.SOLID = 0.76
local TOP = { 0.74, 0.71, 0.63 }
local TOP_ALT = { 0.67, 0.64, 0.57 }
local texture = nil
-- A small deterministic scatter, for the surface itself. Not a random one: an
-- authored constant that happens to look unpatterned is worth more here than
-- a seed, because it can never change under a different Lua.
--
-- Quantised into blocks, so the surface reads as TEXELS rather than as noise.
-- Per-pixel it came out as a fine mottle that fought the dithered rim for
-- attention -- and the rim is the thing worth looking at. At this size the
-- grain is roughly the size of the voxels everywhere else in the mode.
StadiumStage.GRAIN = 4
local function grain(x, y)
local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN
local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN
local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100
return v < 34
end
-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than
-- random because a random dither crawls: this pattern is fixed in the
-- texture, so the fade holds still while the camera drifts across it.
local BAYER = {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 },
}
function StadiumStage.texture()
if texture ~= nil then return texture or nil end
local ok, img = pcall(function()
local n = StadiumStage.TEX
local data = love.image.newImageData(n, n)
local half = (n - 1) / 2
local solid = StadiumStage.SOLID
for y = 0, n - 1 do
local dy = (y - half) / half
for x = 0, n - 1 do
local dx = (x - half) / half
local d = (dx * dx + dy * dy) ^ 0.5
-- how much of this texel's neighbourhood should survive: everything
-- inside the solid core, nothing past the rim, and a smooth ramp
-- between the two that the dither turns into a stipple
local cover
if d <= solid then
cover = 1.0
elseif d >= 1.0 then
cover = 0.0
else
local t = (d - solid) / (1.0 - solid)
cover = 1.0 - t * t * (3 - 2 * t) -- smoothstep, falling
end
local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64
local a = (cover > threshold) and 1 or 0
local c = grain(x, y) and TOP_ALT or TOP
data:setPixel(x, y, c[1], c[2], c[3], a)
end
end
local image = love.graphics.newImage(data)
-- nearest, like every other texture in this mode: the grain and the
-- stipple are both meant to read as texels. Clamped rather than
-- repeating now that one texture covers the whole disc.
image:setFilter("nearest", "nearest")
image:setWrap("clampzero", "clampzero")
return image
end)
texture = (ok and img) or false
return texture or nil
end
-- ------- the mesh
--
-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with
-- the whole texture stretched across it. The DISC is the texture's business,
-- not the geometry's -- everything outside the painted circle is alpha the
-- shader discards -- which is what "a flat texture that fades out at the
-- edges" means and what makes this four vertices rather than a hundred and
-- fifty.
--
-- Shaded as a face pointing straight up, because it is one.
local mesh = nil
local function build()
local s = shadeFor(0, 1, 0)
local verts = {
{ -1, 0, -1, 0, 0, s },
{ 1, 0, -1, 1, 0, s },
{ 1, 0, 1, 1, 1, s },
{ -1, 0, 1, 0, 1, s },
}
return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 })
end
function StadiumStage.mesh()
if mesh == nil then mesh = build() or false end
return mesh or nil
end
function StadiumStage.invalidate()
if texture and texture.release then pcall(texture.release, texture) end
if mesh and mesh.release then pcall(mesh.release, mesh) end
texture, mesh = nil, nil
end
-- How far under the ground plane the disc actually sits. A hair, and only so
-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not
-- coplanar with it and left to the depth buffer's mercy.
StadiumStage.SINK = 0.06
-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon
-- placed at that same height stands ON it rather than in it.
function StadiumStage.matrix(x, groundY, z, radius)
radius = radius or StadiumStage.RADIUS
return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z),
Mat4.scale(radius, 1, radius))
end
-- The two platforms this frame, as (side, matrix) -- shared by the camera's
-- pass and the sun's, so the two can never disagree about where they are.
local function each(arena, groundY, fn)
local ok, Stadium = pcall(V.require, "Stadium")
for _, side in ipairs({ "enemy", "player" }) do
local cell = arena[side]
if cell then
local footprint = ok and Stadium and Stadium.footprint(side) or nil
fn(StadiumStage.matrix(cell[1], groundY, cell[2],
StadiumStage.radiusFor(footprint)))
end
end
end
-- ------- the synthetic arena
--
-- A B rung does not search the map, because it does not stand on it. The
-- arena is the same WIDE shape every other staged fight uses -- so the two
-- cells are three apart down the middle and BattleCam frames them exactly as
-- it always has -- just placed at a fixed spot rather than a found one.
--
-- Away from the origin on purpose. The coordinates run through the camera
-- solve, the sun's frustum fit and the projection to Game Boy pixels, and
-- putting a stage at (0, 0) is the kind of thing that hides a sign error for
-- months.
StadiumStage.ORIGIN = { 16, 16 }
function StadiumStage.arena(map)
local BattleArena = V.require("BattleArena")
local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2],
"wide")
if not arena then return nil end
-- the map is carried for its SKY and its palette only -- what kind of place
-- the fight is happening in -- never for its geometry
arena.map = map
arena.discs = true
return arena
end
-- ------- the draws
-- The discs, in the main pass. No wireframe: everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that, and a
-- disc is a turned solid with no grid to draw.
function StadiumStage.draw(arena, groundY)
if not (arena and arena.discs) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
Voxel3D.seams(false)
Voxel3D.glass(false)
each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end)
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- And into the sun, so the two Pokemon put real shadows on the platforms they
-- are standing on. Without this the shadow map is empty where the discs are
-- and a mon casts onto nothing at all -- which, with no ground behind it
-- either, reads as the pair floating.
function StadiumStage.cast(shadowMap, arena, groundY)
if not (arena and arena.discs and shadowMap) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end)
end
return StadiumStage
+68 -4
View File
@@ -601,9 +601,22 @@ local PLANTER_SPRAY = { rows = 24, depth = 5 }
-- states no profile, the honest reading is the one the thin standee pools
-- exist for: the foliage stands as a per-pixel slab and keeps the airy
-- silhouette that makes it read as leaves.
-- `squash`, when given, is the PERCENT of its revolved depth every chord
-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back.
--
-- A full revolve assumes the drawing's width is also its depth, which is
-- true of a thing that really is round in plan (a hedge ball, a boulder,
-- a trash can). A TREE is round in its canopy and thin at every other
-- reading: the trunk is a stick, the crown is more air than wood, and the
-- drawing is scenery seen from one side. Revolved at full width the little
-- tree eats a whole cell of depth and reads as a boulder wearing bark, so
-- the plan stays a circle and shrinks toward an ellipse: still round in
-- section, still stepping pixel by pixel, just shallower. The chord is
-- re-centred on the mid-plane, so the model neither slides nor detaches
-- from the cells around it.
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
NYin, spray, baseRows, bodyRows, wellRows,
taperVox)
taperVox, squash)
-- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
-- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
-- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
@@ -864,6 +877,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
+ 0.5))
end
if spray and iy < spray.rows then n = math.min(n, spray.depth) end
if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
-- a row the can's body band was repeated into wears the row it
@@ -975,6 +989,9 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
if squash then
n = math.max(1, math.floor(n * squash / 100 + 0.5))
end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
end
@@ -1298,6 +1315,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
-- voxels the body band is repeated up to
local stumpCap, canCap, canBase, canHeight, canWell, canTaper
= 6, 9, 4, 9, 5, 4
-- the sapling class's depth, as a PERCENT of the revolved chord
local saplingSquash = 50
do
local okP, prof = pcall(V.data, "voxel_heights")
local entry = okP and type(prof) == "table" and prof.tilesets
@@ -1320,6 +1339,9 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
if entry and type(entry.can_taper) == "number" then
canTaper = entry.can_taper
end
if entry and type(entry.sapling_squash) == "number" then
saplingSquash = entry.sapling_squash
end
end
-- cells consumed by a 2x2 `canopy` group; the scan runs north to
@@ -1439,13 +1461,19 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
local tall = s.class == "can" and canHeight or nil
local well = s.class == "can" and canWell or nil
local taper = s.class == "can" and canTaper or nil
-- 100% is the full revolve, so it is the identity: never signed
-- into the cache key, and never passed, by a class that has no
-- squash of its own
local squash = (s.class == "sapling" and saplingSquash ~= 100)
and saplingSquash or nil
local ground = false
if data then
local sig = tsid .. (cap and ("|c" .. cap) or "")
.. (base and ("|b" .. base) or "")
.. (tall and ("|h" .. tall) or "")
.. (well and ("|w" .. well) or "")
.. (taper and ("|t" .. taper) or "") .. "|"
.. (taper and ("|t" .. taper) or "")
.. (squash and ("|q" .. squash) or "") .. "|"
.. gsig .. "|" .. table.concat({
S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
@@ -1454,7 +1482,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
if not tpl then
local tq, tbg = roundTemplate(S, map, data, cx, cy,
groundTiles, 16, cap, nil, nil,
base, tall, well, taper)
base, tall, well, taper, squash)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
@@ -3480,13 +3508,49 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
-- Stable diagonal phase per tuft. Both ends of every quad receive
-- the same value, so a gust bends the slab without shearing it.
local sway = wx * 0.050 + wz * 0.031
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
uv = q.uv, shade = q.shade,
uv = q.uv, shade = q.shade, sway = sway,
cx = wx + 4, cz = wz + 4,
}
end
-- Sparse wind-borne leaf. It reuses one opaque grass texel and is
-- animated entirely on the GPU, so no per-frame Lua particles exist.
if #tpl > 0 and ((tx * 13 + ty * 7) % 11 == 0) then
local src = tpl[1]
local uv = src.uv and src.uv[1] or { src.u, src.v }
local lx = wx + 2 + ((tx * 5 + ty * 3) % 5)
local lz = wz + 4
local ly, size = 9 + ((tx + ty) % 3), 1.25
quads[#quads + 1] = {
{ lx - size, ly, lz }, { lx + size, ly, lz },
{ lx + size, ly + size, lz }, { lx - size, ly + size, lz },
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 0.73,
cx = lx, cz = lz, leaf = true,
}
end
-- Rarer one-pixel firefly. Geometry exists all day, but the shader
-- gives it zero glow outside outdoor night.
if #tpl > 0 and ((tx * 17 + ty * 11) % 29 == 0) then
local src = tpl[1]
local uv = src.uv and src.uv[1] or { src.u, src.v }
local fx = wx + 2 + ((tx * 3 + ty * 5) % 5)
local fz = wz + 4
local fy, half = 9 + ((tx + ty) % 4), 0.5
quads[#quads + 1] = {
{ fx - half, fy, fz }, { fx + half, fy, fz },
{ fx + half, fy + 1, fz }, { fx - half, fy + 1, fz },
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 1.37,
cx = fx, cz = fz, firefly = true,
}
end
end
+9
View File
@@ -80,6 +80,14 @@ local FALLBACK_HEIGHTS = {
-- the drawing's own straight run is only a couple of rows, because a GB
-- cell spends most of itself on the opening
can = 9,
-- the same hull SQUASHED front to back (the profile's sapling_squash,
-- a percent of the revolved depth): the little trees drawn one cell
-- wide -- Celadon Gym's garden trees and the overworld's cuttable
-- tree, which are the same drawing on two atlases. A tree is round in
-- its canopy but is not a boulder: revolved at full width it fills a
-- whole cell of depth, so the plan keeps its circle and shrinks toward
-- an ellipse
sapling = 16,
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
@@ -147,6 +155,7 @@ local ART = {
canopy = "canopy",
stump = "cylinder",
can = "cylinder",
sapling = "cylinder",
planter = "planter",
billboard = "billboard",
-- signposts share the billboard treatment but as their own pool at a
+219 -18
View File
@@ -20,7 +20,17 @@
-- battle or wipe is what the flat screen is showing
-- xrEndFrame with the projection layer and/or the quad
--
-- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit
-- WHICH VR YOU GET is the row's own rung first (see VR.setting), and only
-- then the VOXEL ladder. STANDARD is the mode described below. The two
-- DIORAMA rungs are one presentation instead of a ladder -- the world is
-- always a model on the table, cut to a square viewport (a ball with a
-- dissolved rim while V-CURVE is on) that the grips pick up, turn and
-- open out, with a staged fight arriving as a floating disc of map.
-- lib/Diorama owns all of that; what this file owns is pointing the
-- mapping at it. DIORAMA-MR is the same with the background keyed green
-- for a mixed-reality capture.
--
-- Within STANDARD, which VR you get mirrors the VOXEL ladder: on the orbit
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
-- your head started -- lean in, walk around it; on 1ST you stand inside
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
@@ -52,12 +62,30 @@ local VRRig = V.require("VRRig")
local VRXR = V.require("VRXR")
local VRGL = V.require("VRGL")
local Pokedex = V.require("Pokedex")
local Diorama = V.require("Diorama")
local VR = {}
-- the row: plain OFF/ON. No hotkey -- the engine's display keys are
-- spoken for, and a headset is not something to toggle by accident.
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
-- The row: OFF, and then WHICH VR. No hotkey -- the engine's display keys
-- are spoken for, and a headset is not something to toggle by accident.
--
-- STANDARD what this mod shipped: the headset follows the VOXEL
-- ladder, orbit rungs becoming a tabletop and 1ST standing
-- you inside the world at life size.
-- DIORAMA one presentation instead of a ladder -- the world is
-- always a model on the table, cut to a viewport you can
-- pick up, turn and open out (see lib/Diorama). There is no
-- 2D and no first person in it: both are a different promise
-- about where the player is standing.
-- DIORAMA-MR the same, with the background keyed pure green for a
-- mixed-reality capture.
--
-- `true` is still STANDARD's stored value, deliberately: the row used to be
-- a toggle, and a save that stored it as one must come back on the rung it
-- was left on rather than falling to OFF.
VR.setting = ModSetting.new("vr", "VR",
{ false, true, "diorama", "diorama-mr" },
{ "OFF", "STANDARD", "DIORAMA", "DIORAMA-MR" })
-- How the right stick turns you in first person. OFF is the 45-degree
-- SNAP this mod shipped with and the reason for it is comfort, not
@@ -152,8 +180,27 @@ function VR.supported()
return os == "Windows"
end
-- Which VR the row is asking for: "off", "standard", "diorama" or
-- "diorama-mr". The one place the stored value is interpreted -- everything
-- else asks this, so a rung added to the ladder is a change here and
-- nowhere else.
function VR.mode()
if not VR.supported() then return "off" end
local v = VR.setting:get()
if v == true then return "standard" end
if v == "diorama" or v == "diorama-mr" then return v end
return "off"
end
function VR.enabled()
return VR.supported() and VR.setting:get() == true
return VR.mode() ~= "off"
end
-- Whether the row is on one of the DIORAMA rungs -- the modes where the
-- world is a model with an edge to it rather than a place to stand in.
function VR.dioramaMode()
local m = VR.mode()
return m == "diorama" or m == "diorama-mr"
end
function VR.active()
@@ -208,6 +255,9 @@ local function shutdown(reason)
zoom, heightOff = 1, 0
fpYawOff, snapArmed = 0, true
camMode, fadeAlpha = "explore", 0
-- the model goes back on the table where it started: the grab, the turn,
-- the viewport's size and the meshes cut for it
Diorama.reset()
status = reason or "off"
end
@@ -286,10 +336,47 @@ local function renderWorld(views, ctl)
-- three cells behind their own body is a well-known way to make people
-- ill. The rung still changes the walk and the cards the same way; only
-- the eye stays where a head belongs.
local fp = FirstPerson.engaged()
--
-- A DIORAMA mode never does either: the world is a model on the table
-- whatever the rung says, so first person is refused here rather than
-- being made to work at a scale it does not mean.
local dio = VR.dioramaMode()
local fp = (not dio) and FirstPerson.engaged()
local battle, battleFloor
if camMode == "battle" then battle, battleFloor = battleStage() end
if battle then
if dio then
-- ------- the diorama modes
--
-- The model presents exactly as the standard view frames it -- the
-- pivot VIEW_DIST away along the rung's angle, at the scale that
-- reproduces that framing -- and then everything the player has done
-- to it goes on top: the carry, the turn, the stick's zoom.
--
-- A STAGED FIGHT does not move the head here (that is the standard
-- mode's over-the-shoulder seat, and it is a first-person answer):
-- the MODEL re-centres on the arena and the viewport becomes a
-- vertical pillar about it, so the fight arrives as a disc of map
-- lifted out of the world and left floating on the table.
-- what the model is FRAMED to fill: the view the flat screen would
-- have shown ordinarily, and the DISC itself while a fight is staged
-- -- a disc left at map scale is a coin on a table across the room.
local frame = vh
if battle then
pivot = VRRig.dioramaPivot(battle.mid[1], battle.mid[2])
local cut = Diorama.pillar(battle)
if cut then frame = cut.r * 2.6 end
else
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
Diorama.viewport(pivot[1], pivot[3], vh)
end
anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom
-- the hand-turn, and -- while a fight is staged -- the arena's own
-- quarter turn taken back out, so a turned arena arrives on the table
-- facing the head rather than lying across it (Diorama.battleYaw)
local dioYaw = battle and Diorama.battleYaw(battle) or Diorama.yaw
if dioYaw ~= 0 then mountYaw = dioYaw end
elseif battle then
-- the over-the-shoulder seat the flat battle shot stands in, pulled
-- close enough for a headset's own lens (see VRRig.battleMount), at
-- life scale, turned to face the arena
@@ -332,8 +419,11 @@ local function renderWorld(views, ctl)
-- seat, where its screen is the fight's own 2D scene. The diorama
-- does without: a hand-sized device hovering over a tabletop town is
-- clutter, and the panel serves there. No hand tracked, no device.
-- (`not dio` for the same reason the diorama never had one: a hand-sized
-- device hovering over a tabletop town is clutter, and that is as true
-- of a tabletop FIGHT -- the panel serves both.)
local hand = ctl and ctl.handl or nil
if hand and (battle or fp) then
if hand and not dio and (battle or fp) then
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
if uiShowing() then
local scr = dexScreen()
@@ -380,11 +470,25 @@ local function renderWorld(views, ctl)
end
end
-- THE WORLD CURVE, for the diorama modes alone. Standing inside a bent
-- world is what first person declines on the flat screen too, and the
-- battle mount is a placed shot -- but a diorama is a model being looked
-- AT, so the bend turns it into a little globe curling over its own
-- horizon, which is the whole point of the throw the left stick's click
-- makes. Measured against the FLAT view height, so a rung's bend is the
-- same bend the flat screen would have drawn.
--
-- It bends about the scene centre, which for these eyes is the pivot --
-- the model's own middle -- so the globe is centred on the model rather
-- than on wherever a head happens to be standing.
local curveK = dio and V.require("WorldCurve").k(vh) or 0
local eyes = {}
for i = 1, 2 do
local v = views[i]
eyes[i] = {
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw,
curveK),
w = v.w, h = v.h,
slot = i == 1 and "vrL" or "vrR",
-- the battle seat is a placed shot, not the first-person rig: the
@@ -518,6 +622,13 @@ end
-- and moving that hand up or down drags the whole table
-- with it.
--
-- The DIORAMA modes rebind two of those, because in them there is no
-- ladder to step and no table-height to be the only thing worth dragging:
--
-- left stick click throws V-CURVE to its top rung and back.
-- grips one carries the model anywhere in the room; both
-- turn it and open the viewport out (Diorama.gesture).
--
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
-- no controller button does it. VR.leave below stays as the API for it.
@@ -537,6 +648,61 @@ function VR.stepView()
end)
end
-- Put the VOXEL ladder on a given rung, for the one caller that needs to
-- rather than to step: a DIORAMA mode holding the ladder off 2D and off
-- both free-roam rungs (see dioramaRung). Handed over by main.lua next to
-- cycleVoxel and for the same reason.
VR.setVoxelLevel = nil -- setVoxelLevel(game, level), set by main.lua
-- The rung a diorama mode holds the ladder on when it finds it somewhere
-- the mode cannot present: 35 degrees, the standard view's own angle.
VR.DIORAMA_RUNG = 3
-- 2D is not a diorama and neither is standing inside the world, so while a
-- diorama mode is live the ladder is held on an orbit rung. Cheap enough to
-- ask every frame: it is a table read and, almost always, no write.
local function dioramaRung()
pcall(function()
if not VR.setVoxelLevel then return end
local Pipelines = require("src.render.Pipelines")
local level = Pipelines.level("voxel") or 0
if level == 0 or Voxel.isFreeCam(level) then
VR.setVoxelLevel(require("src.core.Game"), VR.DIORAMA_RUNG)
end
end)
end
-- The V-CURVE row, thrown to its top rung and back -- what the left stick's
-- click does in a diorama, where there is no ladder for it to step.
--
-- A toggle rather than a cycle, because in a headset the curve is not a
-- taste setting with four values: it is the one control that decides
-- whether the model is a flat slab of map or a little world curling away
-- over its own horizon, and the player wants to see both, now, without
-- counting clicks. The rung it was on is remembered so the click gives it
-- back rather than dropping the row to OFF.
--
-- It changes the CUT with it (see lib/Diorama): flat world, square box,
-- hard edge; curved world, ball, dissolve. One click swaps the whole
-- reading of the model, which is why it is the click worth having here.
local curveWas = nil
function VR.toggleCurve()
pcall(function()
local Game = require("src.core.Game")
local WorldCurve = V.require("WorldCurve")
local top = WorldCurve.setting.values[#WorldCurve.setting.values]
if WorldCurve.setting:get() == top then
WorldCurve.setting:setValue(curveWas or WorldCurve.setting.values[1],
Game)
curveWas = nil
else
curveWas = WorldCurve.setting:get()
WorldCurve.setting:setValue(top, Game)
end
end)
end
-- Leave VR: the VR row toggled back off and persisted, exactly as if
-- stepped on the OPTIONS menu, so the next update tears the session down
-- and the flat screen takes the picture back. Deliberately bound to NO
@@ -545,7 +711,9 @@ end
function VR.leave()
pcall(function()
local Game = require("src.core.Game")
VR.setting:setIndex(VR.setting:read() + 1, Game)
-- OFF by VALUE, not by stepping the row: the row is a ladder now, and
-- one step off STANDARD is DIORAMA rather than the way out
VR.setting:setValue(false, Game)
end)
end
@@ -559,9 +727,10 @@ local function setGB(inp, btn, down)
end
end
local function driveControls(ctl, dt, fp)
local function driveControls(ctl, dt, fp, dio)
if not ctl then
releaseInputs()
Diorama.releaseGrab()
return
end
local ok, Game = pcall(require, "src.core.Game")
@@ -595,11 +764,18 @@ local function driveControls(ctl, dt, fp)
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
-- the left stick click: the VOXEL ladder ordinarily, and the way out of
-- horde mode while it runs (the rung is locked there, so the click has
-- nothing else to do, and a headset has no ESCAPE key)
-- the left stick click: the VOXEL ladder ordinarily, the V-CURVE throw in
-- a diorama (where the ladder is held on one rung and the click would
-- otherwise do nothing), and the way out of horde mode while it runs (the
-- rung is locked there too, and a headset has no ESCAPE key)
if ctl.toggleChanged and ctl.toggle then
if Horde.active then Horde.askExit() else VR.stepView() end
if Horde.active then
Horde.askExit()
elseif dio then
VR.toggleCurve()
else
VR.stepView()
end
end
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
@@ -634,6 +810,20 @@ local function driveControls(ctl, dt, fp)
end
end
-- THE DIORAMA'S GRIPS take the model itself: one hand carries it through
-- the room, both turn it and open the viewport out (see Diorama.gesture).
-- The stick's zoom still sizes the model under all of that -- the two
-- are different questions, "how big is it" and "how much of it is there".
if dio then
lastHandY = nil
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
end
Diorama.gesture(ctl)
return
end
if not fp and camMode ~= "battle" then
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
@@ -659,7 +849,8 @@ end
-- ------- the per-frame drive
function VR.update(dt)
local on = VR.enabled()
local mode = VR.mode()
local on = mode ~= "off"
if not on then
if wasOn then
shutdown("off")
@@ -703,6 +894,12 @@ function VR.update(dt)
pcall(love.window.setVSync, 0)
end
-- Which VR this frame is, before anything reads it: the diorama's own
-- fields (the viewport, the chroma key) are open for the length of the
-- frame and shut with the session. The rung guard rides it -- there is
-- no 2D diorama and no first-person one.
if Diorama.begin(mode) then dioramaRung() end
-- the battle camera holds still for as long as a headset is watching:
-- its drift is a flat screen's depth cue, and a swaying picture inside
-- VR reads as the world lurching
@@ -727,8 +924,9 @@ function VR.update(dt)
-- flips rungs on should be the frame that renders the new rig. The
-- state is kept in hand for renderWorld too -- the pokedex stands on
-- the same frame's left-hand pose.
local dio = VR.dioramaMode()
local ctl = VRXR.input(time)
driveControls(ctl, dt, FirstPerson.engaged())
driveControls(ctl, dt, (not dio) and FirstPerson.engaged(), dio)
local worldUp = false
if should then
@@ -737,7 +935,9 @@ function VR.update(dt)
worldUp = renderWorld(views, ctl)
end
end
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
-- the diorama's panel is the tabletop one whatever the rung says: there
-- is no first person in the mode to float it closer for
local quadPose = updateQuad(worldUp, (not dio) and FirstPerson.engaged())
VRXR.endFrame(time, worldUp or nil, quadPose)
end
@@ -778,6 +978,7 @@ function VR.invalidate()
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
dexCanvas = nil
Pokedex.invalidate()
Diorama.invalidate() -- the base's mesh and its cave-floor texture
V.require("HordeGun").invalidate()
V.require("HordeHud").invalidate()
for k in pairs(fboCache) do fboCache[k] = nil end
+36 -10
View File
@@ -62,13 +62,25 @@ VRRig.VIEW_DIST = 0.95
-- and (-d sin a) ahead of the resting head reproduces exactly that line
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
-- onto 75 and it rises toward eye level, easing between them as the rung
-- tween runs. `heightOff` is the grab-drag adjustment, in metres of world
-- travel (positive drags the world up).
function VRRig.dioramaAnchor(angleRad, heightOff)
-- tween runs.
--
-- `off` is the grab-drag adjustment, in metres of LOCAL travel -- where
-- the player has carried the model to. A bare number is the height alone,
-- which is what the standard mode's one-axis drag has always sent; the
-- DIORAMA modes hand over all three (see lib/Diorama). Positive Y drags
-- the world up: the anchor is the LOCAL point pinned to the pivot, so
-- moving it moves the model with the hand rather than against it.
function VRRig.dioramaAnchor(angleRad, off)
local d = VRRig.VIEW_DIST
return { 0,
-d * math.cos(angleRad or 0) + (heightOff or 0),
-d * math.sin(angleRad or 0) }
local ox, oy, oz = 0, 0, 0
if type(off) == "table" then
ox, oy, oz = off[1] or 0, off[2] or 0, off[3] or 0
elseif type(off) == "number" then
oy = off
end
return { ox,
-d * math.cos(angleRad or 0) + oy,
-d * math.sin(angleRad or 0) + oz }
end
-- The diorama's scale, in world px per metre: the one that makes the
@@ -133,12 +145,26 @@ VRRig.FAR = 400
-- yaw optional turn of the whole mapping about +Y, radians: the
-- battle mount faces the resting head at the arena with it.
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
-- curveK the world curve this eye is to be drawn with (see WorldCurve);
-- omitted is 0, the curve DECLINED.
--
-- Off by default because standing inside a bent world is what first person
-- already declines on the flat screen, and the battle mount is a placed
-- shot. The DIORAMA modes are the case that wants it and asks for it: the
-- model is a thing being looked AT, so bending it into a little globe is
-- the whole point rather than a broken tabletop -- and it is what the left
-- stick's click throws (see lib/VR). Passed in rather than read here
-- because a rig has no business deciding what a row means.
--
-- Beware the shape of the answer: Voxel3D reads `camera.curve` with `or`,
-- and 0 is TRUE in Lua, so a 0 here really does pin the bend off -- which
-- is exactly why the diorama's curve did nothing until this became a
-- parameter.
--
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
-- eye and focus (for setLook, the water's lean, the sky), fov as a
-- vertical span, and the curve declined -- a bent tabletop reads as a
-- broken model, and first person already declines it on the flat screen.
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
-- vertical span, and that curve.
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw, curveK)
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
local q = pose.quat
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
@@ -199,7 +225,7 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
eye = { ex, ey, ez },
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
fov = fov.angleUp - fov.angleDown,
curve = 0,
curve = curveK or 0,
skyRay = skyRay,
}
end
+393
View File
@@ -0,0 +1,393 @@
-- RENDER DIST: how much of the map the orbit rungs bother to draw.
--
-- lib/Diorama cuts a headset's model out of the world with a box the
-- player opens and closes with their hands. This is the same cut on the
-- flat screen, asked the other way round: not "how much world do I want to
-- be holding" but "how much world can this camera actually SEE" -- so that
-- nothing off screen is drawn, and nothing on screen is missing.
--
-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the
-- first cut of this file got it wrong: it took the flat game's own vw-by-vh
-- rectangle about the view centre, which is exactly right at 0 degrees and
-- wrong at every rung the mode actually has. Tilt the camera and the ground
-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching
-- much further north (the far edge of the frame is further away, so it
-- covers more ground per pixel), flaring much wider there for the same
-- reason, and pulling IN at the south edge, which is nearer the eye than
-- the focus is. A window-sized box cuts the north field and both far
-- corners off a world that is plainly on screen: gaps at the top and down
-- the sides, with sky showing through them.
--
-- So the footprint is derived from the camera rather than guessed. The
-- orbit is one number (see Voxel3D.viewProjection): eye at distance
-- FOCAL*vh, pitched `a` off straight down, looking at the view centre,
-- with a fov chosen so a straight-down camera frames exactly vh. Cast the
-- frame's own corner rays at the ground plane and the trapezoid falls out
-- 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
-- trapezoid is -- the box sits north of the view centre at every rung but
-- the top one.
--
-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly
-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the
-- trapezoid stops being finite: the camera can see to the horizon, and "all
-- the ground on screen" is an infinite answer. Something has to name a
-- distance, and that is what RENDER DIST names -- MAX_REACH view heights,
-- times the row's own multiplier. Below that pitch the row does nothing to
-- the picture at all, because the honest footprint is already smaller than
-- the reach; at 75 it is what decides where the world ends.
--
-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is
-- one VoxelScene can test a whole neighbour map against BEFORE drawing it
-- -- see shows() -- so a connected map that lands entirely outside the box
-- costs no terrain mesh, no water, no grass, no flowers and no shadow
-- pass. Most of the win is at the HIGH rungs, where the camera is nearly
-- overhead and the footprint is barely bigger than the window; at 75 it
-- sees half the region and skips almost nothing, which is the truth about
-- that rung rather than a shortcoming of the test.
--
-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is
-- standing IN the world there, and a box around a walking eye is a
-- fog-of-war circle rather than a model on a table. The rung tween into
-- them opens the box out with the blend rather than dropping it on a
-- frame, so diving into a head does not pop the sides away. A headset's
-- frame is not touched either -- lib/Diorama owns the cut there, and VR's
-- STANDARD rungs are a tabletop already.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local ViewBox = {}
ViewBox.KEY = "viewbox"
-- RENDER DIST rather than a V- name like the rows either side of it: what
-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing
-- every game with this row calls a render distance. The mode read -- the
-- slab with sides -- is what that buys, not what the row is asking.
ViewBox.LABEL = "RENDER DIST"
-- The ladder, as a multiplier on the footprint the camera actually frames.
-- Rung 0 is FIT and it is the default, and FIT means exactly that: the
-- ground on screen and no more. It cannot open a gap -- 1.0 times the
-- honest answer is the honest answer -- so the only thing the wider rungs
-- buy below the horizon pitch is margin around a cut nobody can see.
--
-- Where they DO decide the picture is at 75, and at the tween rungs either
-- side of it, where the footprint is infinite and MAX_REACH below stands in
-- for it: there the ladder is a real render distance and FIT is the closest
-- horizon of the four.
--
-- Geometric rather than even, for the reason WorldCurve's ladder is: what
-- the player sees change between two rungs is the AREA inside the box, and
-- that goes as the square -- even steps bunch the whole ladder at the
-- near end.
--
-- The last rung is 0, which is no cut at all. It sits at the TOP because
-- "everything" is where the ladder is going: FIT, wider, wider, wider,
-- all of it.
ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 }
ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
{ 0, 1, 2, 3, 4 },
{ "FIT", "WIDE", "WIDER", "WIDEST", "OFF" })
-- How far the world may reach when the camera can see the HORIZON and the
-- honest footprint is infinite, in view heights. Generous on purpose: this
-- is a backstop for an unbounded answer, not a curtain to draw across the
-- middle distance, and it wants to land well past the edge of the loaded
-- neighbourhood so the world runs out before the cut does. Multiplied by
-- 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,
-- the box keeps its hard edge -- that IS the sides.
ViewBox.FADE_FRAC = 0.16
-- How far outside the box a map may still have geometry inside it: the
-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32
-- world pixels), which is the one thing a map draws beyond its own
-- rectangle. A neighbour kept by this margin that turns out to be entirely
-- outside is drawn and then cut per fragment, which is what would have
-- happened without the test -- the margin can only cost a draw, never a
-- hole.
ViewBox.PAD = 96
function ViewBox.level()
return ViewBox.setting:get() or 0
end
-- The multiplier in force, or nil for OFF -- which is also every caller's
-- "there is no cut this frame" answer.
function ViewBox.frac()
local f = ViewBox.FRACS[ViewBox.level() + 1]
if not f or f <= 0 then return nil end
return f
end
-- Whether this rung is one the box is about: an ORBIT rung, which is every
-- level the mode has except OFF (level 0, where there is no 3D pass to cut)
-- and the two free-roam rungs (see the header).
function ViewBox.appliesTo(level)
local ok, applies = pcall(function()
local Voxel = V.require("VoxelState")
local l = level or Voxel.level or 0
return l > 0 and not Voxel.isFreeCam(l)
end)
return ok and applies or false
end
-- ------- what the live frame is
--
-- Set by VoxelScene for the length of one flat frame and cleared with it,
-- exactly as Diorama's is for a headset's. Nothing else writes it, and
-- every reader -- the shader uniforms, the neighbour skip -- hangs off this
-- one field being nil or not.
ViewBox.cull = nil -- { x, y, z, r, rx, rz, invFade, kind }
local function curved()
local ok, on = pcall(function()
return V.require("WorldCurve").active()
end)
return ok and on or false
end
-- How far out of the orbit and into a walking head the rung tween has got,
-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has
-- grown past every edge of the frame by the time the eye arrives in the
-- head and the cut is dropped -- rather than the sides vanishing on the
-- frame the rung number changed, which is a pop in the middle of a move.
local function orbitLeft()
local ok, blend = pcall(function()
return V.require("FirstPerson").blendEased()
end)
if not ok or type(blend) ~= "number" then return 1 end
return 1 - math.max(0, math.min(1, blend))
end
-- ------- the ground this camera frames
--
-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance
-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus;
-- focus on the ground at the view centre; a symmetric frustum whose
-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh)
-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the
-- TOP of the frame, which is north.
--
-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY,
-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a),
-- up = (0, sin a, -cos a)) that comes out as
--
-- d = ( sx*tanX, -cos a + sy*tanY*sin a, -sin a - sy*tanY*cos a )
--
-- Drop it to the ground plane from an eye at height k*cos a and the whole
-- trapezoid collapses to ONE denominator,
--
-- D(sy) = cos a - sy*tanY*sin a
--
-- with (the sin^2 + cos^2 cancels most of the algebra away):
--
-- north of centre : (vh/2) * sy / D(sy)
-- half-width : (vw/2) * cos a / D(sy)
--
-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL
-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is
-- the case the first cut of this file mistook for all of them.
--
-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and
-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon
-- sits on. Past 63 degrees that row is inside the frame and the answer is
-- infinite -- which is what `reach` is for.
--
-- Returns three DISTANCES from the view centre, all positive: how far the
-- picture runs north, how far south, and how far to each side.
function ViewBox.footprint(a, vw, vh, reach)
local Voxel = V.require("VoxelState")
local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
-- the orbit never reaches level (75 degrees is the last rung) but a tween
-- reads a live angle, and a cos of zero is a horizon through the middle
-- of the frame rather than a number
local ca = math.max(math.cos(a or 0), 1e-3)
local sa = math.max(math.sin(a or 0), 0)
-- The screen row the far edge is taken at: the TOP of the frame, or the
-- row whose ray lands `reach` out, whichever comes first. Inverting the
-- north formula for sy gives
--
-- sy = reach*cos a / (vh/2 + reach*tanY*sin a)
--
-- which is always strictly below the horizon row (it approaches it from
-- underneath as reach grows), so D below is always positive -- with the
-- horizon in frame this never even reaches 1 and the clamp is inert.
local sy = reach * ca / (halfH + reach * tanY * sa)
if sy > 1 then sy = 1 end
local D = ca - sy * tanY * sa
if D < 1e-3 then D = 1e-3 end
return halfH * sy / D, -- north
halfH / (ca + tanY * sa), -- south: the sy = -1 row
halfW * ca / D -- and the widest row is the far one
end
-- Open the frame's cut: the bounding rectangle of the ground this camera
-- frames, times the row's multiplier. Returns the cut, or nil when this
-- frame has none -- which is the row at OFF, a rung the box is not about,
-- and a camera that has finished its dive into a head.
function ViewBox.frame(cx, cy, vw, vh, level)
ViewBox.cull = nil
local frac = ViewBox.frac()
if not (frac and ViewBox.appliesTo(level)) then return nil end
local left = orbitLeft()
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(
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
-- picture ahead of the view centre than behind it -- at 35 degrees
-- roughly twice as much, at 75 the whole frame.
--
-- The same floor Diorama.radius keeps: a box smaller than a couple of
-- tiles is not a viewport, it is a hole the player is standing in.
local rx = math.max(24, side)
local rz = math.max(24, (north + south) * 0.5)
local bent = curved()
local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0
ViewBox.cull = {
x = cx, y = 0, z = cy - (north - south) * 0.5,
-- `r` is what the ball and the pillar kinds are sized by and the box
-- is not; carried so the cut table has one shape whoever made it
r = math.max(rx, rz), rx = rx, rz = rz,
-- a zero band is a hard edge: half a pixel of ramp, which is one pixel
-- of antialiasing rather than a stair (Diorama says the same)
invFade = 1 / math.max(fade, 0.5),
kind = V.require("Diorama").BOX,
}
return ViewBox.cull
end
function ViewBox.stop()
ViewBox.cull = nil
end
-- ------- the coarse half of the cut
--
-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can
-- be inside this frame's box. True whenever there is no box, so a caller
-- may guard every draw with it unconditionally.
function ViewBox.shows(x0, z0, x1, z1)
local c = ViewBox.cull
if not c then return true end
local pad = ViewBox.PAD
return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx
and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz
end
-- The same question about a connected neighbour, in the shape VoxelScene
-- keeps them: { map, ox, oy } with the offset in world pixels and the map's
-- own size in blocks of 32 (the shape prefetch's masks are built from).
function ViewBox.showsMap(nb)
if not (nb and nb.map and nb.map.def) then return true end
return ViewBox.shows(nb.ox or 0, nb.oy or 0,
(nb.ox or 0) + (nb.map.def.width or 0) * 32,
(nb.oy or 0) + (nb.map.def.height or 0) * 32)
end
-- What the shadow pass has to notice: WHICH neighbours it drew is now a
-- function of the row, and the row is the one input to that the sun's own
-- signature does not already carry (the centre, the view size and the
-- rung are all in it). Widening the box brings a neighbour back into the
-- light's frustum, and a map recorded without it must be redrawn.
function ViewBox.signature()
return ViewBox.frac() or 0
end
function ViewBox.row()
return ViewBox.setting:row()
end
function ViewBox.sync(value)
ViewBox.setting:sync(value)
end
return ViewBox
+354 -22
View File
@@ -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
@@ -72,6 +87,16 @@ Voxel3D.FACE_SHADE = {
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
// route's coordinates run to a few thousand and mediump has no
// fraction left out there, which would make the rim crawl.
varying LOVE_HIGHP_OR_MEDIUMP vec3 vWorld;
#endif
#ifdef VOXEL_GRID
// model space, one unit per voxel -- see VoxelGrid. Precision matters
// here in a way it does not for a colour: the seam is the FRACTIONAL
@@ -87,9 +112,15 @@ local SHADER = [[
uniform vec3 eye;
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
@@ -108,6 +139,86 @@ 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
// above (the curve is a trick played on the viewer, not weather),
// and per VERTEX: on meshes built a face per voxel the interpolated
// answer is indistinguishable from per-fragment fog at a fraction of
// the cost.
vFog = 0.0;
if (fogInfo.x > 0.0) {
float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y);
vFog = (1.0 - exp(-fogInfo.x * fogRun))
* exp(-max(w.y, 0.0) * fogInfo.z);
}
#ifdef VOXEL_CULL
// THE DIORAMA'S VIEWPORT (see lib/Diorama) is measured per FRAGMENT,
// so this stage's only job is to hand the position over -- and to hand
// over the FLAT one, like the fog and the shadow lookup above: the
// curve is a trick played on the viewer, and letting it drag geometry
// in and out of the viewport would make the rim breathe with the bend.
//
// Per fragment rather than per vertex because the diorama's own base
// is cut into cells far coarser than the rim is wide, and interpolating
// the rim across one of those spilled a whole cell of ground past the
// edge of a staged fight's disc.
vWorld = w.xyz;
#endif
// The curved world (see WorldCurve): drop every vertex by the square
// of how far its column stands from the camera's focus. Applied AFTER
// the shadow lookup above and clear of the wireframe's model space, so
@@ -125,13 +236,66 @@ 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;
}
#endif
#ifdef PIXEL
#ifdef VOXEL_CULL
// The viewport, declared in THIS STAGE ALONE. A uniform declared in both
// defaults to highp in the vertex stage and mediump here, and GLSL ES
// refuses to link a uniform the two stages disagree about -- which is
// not a broken cut but no scene shader at all (lib/Water states the same
// trap at length for `vp`).
uniform vec3 cullAt; // the viewport's centre, in world pixels
uniform vec3 cullShape; // half-size, 1/fade, kind: 1 box, 2 ball,
// 3 the staged fight's pillar
uniform vec2 cullRect; // the BOX's half-extents in x and z, which
// the round kinds have no use for. Two
// numbers because the flat screen's box is
// the WINDOW's own footprint and a window is
// not square (lib/ViewBox); a headset's is,
// and lib/Diorama sends the same half-size
// twice.
// 1 well inside the viewport, 0 outside it, and the rim in between --
// which is a HARD edge for the box (its band is half a pixel wide, so
// the ramp is just the antialiasing) and a dissolve for the other two.
//
// Every kind is unbounded upward and downward on purpose: what is wanted
// is a rectangular (or round) piece cut OUT OF THE MAP, and a cut with a
// lid would take the tops off the trees standing in it.
float dioramaCull(vec3 p) {
if (cullShape.z <= 0.5) return 1.0;
vec3 cd = p - cullAt;
// how far INSIDE the cut this point is, in world pixels: the nearest
// side for the rectangle, the rim for the two round kinds
float inside;
if (cullShape.z < 1.5) {
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
} else if (cullShape.z < 2.5) {
inside = cullShape.x - length(cd); // the ball, under V-CURVE
} else {
inside = cullShape.x - length(cd.xz); // the fight's pillar
}
return clamp(inside * cullShape.y, 0.0, 1.0);
}
#endif
uniform Image sunMap;
uniform float sunDark; // how far into black a shadow goes; 0 = off
uniform float sunBias;
@@ -205,6 +369,7 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform vec3 fogColor; // what the haze is made of (see Voxel3D.fog)
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
@@ -218,6 +383,15 @@ local SHADER = [[
// blending keeps those texels out of the depth buffer, so a model never
// carves a transparent hole out of whatever stands behind it
if (p.a < 0.5) discard;
// and the same for anything the diorama's viewport has faded out
// entirely: past the rim there is no world, and a fully faded fragment
// that still wrote depth would punch a hole in the sky behind it
#ifdef VOXEL_CULL
float cull = dioramaCull(vWorld);
if (cull <= 0.0) discard;
#else
float cull = 1.0;
#endif
// the hour's tint multiplies like the sun terms do: it is LIGHT, the
// same warm or moonlit cast on every surface, not a palette swap
vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
@@ -256,6 +430,11 @@ local SHADER = [[
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// the haze stands between the eye and the SURFACE, so it lands after
// every surface term -- sun, seams, glass -- and before only the
// ghost, which must stay one solid readable shape whatever the
// weather (see below)
rgb = mix(rgb, fogColor, vFog);
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
@@ -263,17 +442,36 @@ local SHADER = [[
// solid silhouette. Last in the chain, so neither the sun nor a voxel
// seam can mottle it.
rgb = mix(rgb, ghostColor, ghost);
return vec4(rgb, 1.0) * color;
// 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.
return vec4(rgb, cull) * color;
}
#endif
]]
-- Two compilations of SHADER: the plain scene, and the same thing with the
-- voxel wireframe compiled in. The wireframe needs shader derivatives
-- (fwidth), the one piece of this a driver can refuse, so it is a separate
-- build rather than a branch -- a refusal costs the grid and nothing else.
-- Compilations of SHADER, by what is compiled INTO it: the voxel
-- wireframe, and the diorama's viewport. Variants rather than branches,
-- for two different reasons.
--
-- The wireframe needs shader derivatives (fwidth), the one piece of this a
-- driver can refuse, so a refusal has to cost the grid and nothing else.
--
-- The viewport carries a world-position varying, and a varying is paid for
-- by every fragment of every frame whether or not anything reads it. The
-- cut only ever exists inside a headset's diorama, so every other frame --
-- the flat screen, and a phone above all -- compiles and binds exactly
-- what it always did.
--
-- Each entry is nil = untried, false = unavailable.
local shaders = { [false] = nil, [true] = nil }
local shaders = {}
local function shaderKey(grid, cull)
return (grid and "grid" or "plain") .. (cull and "+cull" or "")
end
local activeShader = nil -- the variant this pass bound
-- Scene canvases, one per NAMED SLOT. There are exactly two callers and
@@ -356,21 +554,24 @@ local function derivativesOK()
return ok and caps and caps.shaderderivatives == true
end
-- The scene shader. `grid` asks for the wireframe variant, and nil comes
-- back when that one will not build -- callers then fall back to the plain
-- one rather than losing the whole 3D pass.
function Voxel3D.shader(grid)
grid = grid and true or false
if shaders[grid] == nil then
-- The scene shader. `grid` asks for the wireframe variant and `cull` for
-- the diorama's viewport; nil comes back when that combination will not
-- build -- callers then fall back to a plainer one rather than losing the
-- whole 3D pass.
function Voxel3D.shader(grid, cull)
grid, cull = grid and true or false, cull and true or false
local key = shaderKey(grid, cull)
if shaders[key] == nil then
if grid and not derivativesOK() then
shaders[grid] = false
shaders[key] = false
else
local src = grid and ("#define VOXEL_GRID 1\n" .. SHADER) or SHADER
local src = (grid and "#define VOXEL_GRID 1\n" or "")
.. (cull and "#define VOXEL_CULL 1\n" or "") .. SHADER
local ok, sh = pcall(love.graphics.newShader, src)
shaders[grid] = ok and sh or false
shaders[key] = ok and sh or false
end
end
return shaders[grid] or nil
return shaders[key] or nil
end
-- Whether the 3D path can run at all. False on a headless test run (no
@@ -396,6 +597,15 @@ function Voxel3D.newMesh(verts, map)
return mesh
end
function Voxel3D.newGrassMesh(verts, map)
if #verts == 0 then return nil end
local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.GRASS_FORMAT, verts,
"triangles", "static")
if not ok then return nil end
if map and #map > 0 then pcall(mesh.setVertexMap, mesh, map) end
return mesh
end
-- The quad corner offsets and UV corners for one face direction, in the
-- order the vertex map below stitches into two triangles. Corners are unit
-- offsets from the voxel's (x, y, z) minimum corner.
@@ -687,6 +897,30 @@ end
-- answers it, so a caller that never does draws exactly what it always drew.
Voxel3D.tint = { 1, 1, 1 }
-- The map's haze, set the same way (VoxelScene and BattleScene ask
-- ForestAtmos, who knows which maps have weather): a table of
-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day.
-- nil -- the default -- sends density 0, so a caller that never heard of
-- fog draws exactly what it always drew, and no pass can inherit the
-- last one's weather.
Voxel3D.fog = nil
-- THE DIORAMA'S VIEWPORT, set the same way (VoxelScene asks lib/Diorama,
-- who is told by lib/VR what the headset is doing): a table of
-- { x, y, z, r, invFade, kind }, kind 1 for the ball and 2 for the staged
-- fight's pillar. nil -- the default, and what every flat frame leaves it
-- at -- sends kind 0, which is the shader's "draw the whole world".
--
-- A plain field rather than a require of lib/Diorama, and deliberately:
-- this file is the bottom of the stack and everything else in the mode is
-- built on it, so it learns about the diorama the same way it learns about
-- the weather and the hour -- by being handed the answer.
Voxel3D.cull = nil
-- What the background is cleared to INSTEAD of the sky, or nil for the
-- sky: DIORAMA-MR's chroma key, set for the eye passes alone.
Voxel3D.keyColor = nil
-- The window-glass pass, set the same way and for the same reason: the
-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
-- panes are belongs to the hour and to being outdoors at all
@@ -810,12 +1044,20 @@ end
-- `slot` names which cached canvas to render into (see `slots` above);
-- omitted is the free-roam world pass.
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- the wireframe variant when the player has it on AND it built; either
-- answer falls through to the plain scene rather than to no scene
-- the wireframe variant when the player has it on AND it built, and the
-- viewport variant while a diorama frame is open; either answer falls
-- through to a plainer scene rather than to no scene. The cut is dropped
-- LAST, because losing it draws a whole uncut world where a model should
-- be, which is worse than losing the seams.
local grid = VoxelGrid.enabled()
local sh = grid and Voxel3D.shader(true) or nil
local cut = Voxel3D.cull ~= nil
local sh = grid and Voxel3D.shader(true, cut) or nil
if not sh then
grid, sh = false, Voxel3D.shader()
grid = false
sh = Voxel3D.shader(false, cut)
end
if not sh and cut then
cut, sh = false, Voxel3D.shader(false, false)
end
if not sh then return false end
local name = slot or "world"
@@ -864,10 +1106,18 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- pitch is the rung's -- the classic frame-hung painting stands.
local skyRay = Voxel3D.skyRayLive
local hy = Voxel3D.horizonY(h)
-- DIORAMA-MR: the background is a CHROMA KEY, so there is no sky at all
-- -- not a green one painted over, but no bands, no disc and no haze,
-- because every one of those is a colour a keyer would have to survive.
-- The world itself is untouched; only what is behind it changes.
local key = Voxel3D.keyColor
if key then sky = nil end
-- where the sky's bottom edge lands, which is what the reflection
-- reads its bands against (see Water). nil when nothing painted bands.
Voxel3D.skyEdge = (sky and sky.bands) and Sky.region(h, hy) or nil
if sky then
if key then
love.graphics.clear(key[1], key[2], key[3], 1, true, true)
elseif sky then
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
-- The sky goes down here, in the one window in this function where a
-- rectangle is just a rectangle: the depth mode and the scene shader are
@@ -922,6 +1172,23 @@ 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 })
pcall(sh.send, sh, "fogInfo", fog and
{ fog.density or 0, fog.start or 0, fog.heightK or 0, 0 }
or { 0, 0, 0, 0 })
-- and the viewport (see Voxel3D.cull), kind 0 when there is none --
-- which is every frame that neither a headset's diorama (lib/Diorama)
-- nor an orbit rung's window box (lib/ViewBox) has cut
local cull = Voxel3D.cull
pcall(sh.send, sh, "cullAt",
cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 })
pcall(sh.send, sh, "cullShape",
cull and { cull.r, cull.invFade, cull.kind } or { 0, 0, 0 })
pcall(sh.send, sh, "cullRect",
cull and { cull.rx or cull.r, cull.rz or cull.r } or { 0, 0 })
-- the window glass: the tileset's mask (or the blank -- the sampler is
-- declared either way, and unbound is a driver-dependent crash), how lit
-- the panes are, and the movement-fed glint as the caller last set it
@@ -936,6 +1203,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0)
-- on until a sprite pass says otherwise, reset per frame like `ghost`
pcall(sh.send, sh, "glassOn", 1)
-- still until the dedicated grass pass enables it
pcall(sh.send, sh, "grassWind", { 0, 0, 0, 0 })
pcall(sh.send, sh, "grassPlayer", { -100000, -100000, 1, 0 })
pcall(sh.send, sh, "grassPrevious", { -100000, -100000 })
-- the curved world bends about the camera's focus, so the horizon keeps
-- a fixed distance ahead of the player rather than sitting on the map.
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
@@ -1127,6 +1398,25 @@ function Voxel3D.endWater()
if activeShader then love.graphics.setShader(activeShader) end
end
-- A custom shader for the length of a draw, inside the pass. What makes
-- this a pair rather than a bare setShader at the call site is the way
-- BACK: the scene shader this pass bound is module-local (activeShader,
-- above), so only this file can restore it -- the same restore endWater
-- performs, without the canvas shuffle. Answers false when there is no
-- pass to come back to, and the caller skips its draw entirely.
function Voxel3D.beginEffect(shader)
if not (active and shader) then return false end
love.graphics.setShader(shader)
love.graphics.setColor(1, 1, 1, 1)
return true
end
function Voxel3D.endEffect()
if not active then return end
love.graphics.setColor(1, 1, 1, 1)
if activeShader then love.graphics.setShader(activeShader) end
end
-- Whether a reflective water pass can run in this frame at all -- there is
-- a depth texture to read. Callers use it to choose between the water
-- shader and an ordinary terrain draw before they start moving canvases.
@@ -1159,6 +1449,30 @@ function Voxel3D.seams(on)
on and VoxelGrid.DARK or 0)
end
-- ADDITIVE for the length of a draw, or nil to put the pass back the way
-- it was found.
--
-- Exactly one thing asks for this: the flame and gas primitives on a
-- STADIUM battle model (Charmander's tail, Weezing's cloud -- see
-- StadiumRig). Those are light, not surface: they are drawn over a body
-- that is already in the depth buffer and they must ADD to it rather than
-- replace it, or the flame comes out as an opaque orange sticker.
--
-- Depth WRITES go off with the blend, and for the usual reason -- a
-- translucent thing that wrote depth would punch whatever comes after it
-- out of the frame. The test stays on, so a flame behind a tree is still
-- behind the tree.
function Voxel3D.blend(mode)
if not active then return end
if mode == "add" then
pcall(love.graphics.setBlendMode, "add", "alphamultiply")
pcall(love.graphics.setDepthMode, "lequal", false)
else
pcall(love.graphics.setBlendMode, "alpha", "alphamultiply")
pcall(love.graphics.setDepthMode, "lequal", true)
end
end
-- Whether what is drawn next may consult the glass mask. false for the
-- length of a sprite-sheet pass, true to put it back.
--
@@ -1172,6 +1486,24 @@ function Voxel3D.glass(on)
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
end
-- Enable wind only around tall-grass draws. px/pz are the current player's
-- feet in world pixels; previous values make collision continuous per frame.
function Voxel3D.grassWind(on, px, pz, previousX, previousZ)
if not (active and activeShader) then return end
if not on then
pcall(activeShader.send, activeShader, "grassWind", { 0, 0, 0, 0 })
return
end
local now = love.timer and love.timer.getTime and love.timer.getTime() or 0
pcall(activeShader.send, activeShader, "grassWind",
{ 1, now, Voxel3D.GRASS_WIND_PIXELS, Voxel3D.GRASS_WIND_SPEED })
pcall(activeShader.send, activeShader, "grassPlayer",
{ px or -100000, pz or -100000,
Voxel3D.GRASS_INTERACT_RADIUS, Voxel3D.GRASS_INTERACT_PIXELS })
pcall(activeShader.send, activeShader, "grassPrevious",
{ previousX or px or -100000, previousZ or pz or -100000 })
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
+159 -26
View File
@@ -15,6 +15,7 @@ local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
local Shadows = V.require("Shadows")
local ChunkMesher = V.require("ChunkMesher")
local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
@@ -27,6 +28,8 @@ local DayNight = V.require("DayNight")
local FirstPerson = V.require("FirstPerson")
local BattleBillboard = V.require("BattleBillboard")
local Pokedex = V.require("Pokedex")
local Diorama = V.require("Diorama")
local ViewBox = V.require("ViewBox")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -622,10 +625,12 @@ local function drawCast(state, posed, atlasFor)
ShadowMap.snug(caster))
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
ShadowMap.snug(caster))
end)
if ViewBox.showsMap(nb) then
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
ShadowMap.snug(caster))
end)
end
end
-- and the seams are back on for the terrain art that follows: grass and
-- flowers are the world's own drawing, not people
@@ -769,6 +774,11 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- and the sprite cards swap frames as it circles them, so a turn on the
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
put(FirstPerson.signature())
-- and the window box, because WHICH neighbours went into the light is a
-- function of it (see ViewBox.signature): opening the row out brings a
-- map back inside the cut, and a sun map recorded without it would leave
-- that map standing in its own unlit shadow
put(ViewBox.signature())
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
@@ -802,9 +812,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
if not ShadowMap.begin(cx, cy, vw, vh) then return end
ShadowMap.draw(terrain, atlasFor(state.map), nil)
-- The window box's coarse cut, here and at every neighbour loop below
-- (lib/ViewBox): a connected map lying entirely outside this frame's
-- viewport has nothing inside it that could reach the picture, so it is
-- not submitted at all. True for every map whenever there is no box,
-- which is every frame the row is OFF and every headset frame.
for i, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
if ViewBox.showsMap(nb) then
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
end
-- The water surface, which the terrain mesh no longer carries (it is its
-- own reflective pass now -- see Water). The sun still has to see it, or
@@ -812,8 +829,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
-- far plane answers for the surface a shoreline tree's shadow falls on.
ShadowMap.draw(water, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
if ViewBox.showsMap(nb) then
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
end
-- flower billboards live outside the terrain mesh (they draw after the
-- characters, pulled -- see render), but the sun still sees them: a
@@ -824,8 +843,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
ShadowMap.snug(nil))
for _, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
if ViewBox.showsMap(nb) then
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
end
-- From here down it is the CAST, marked as such in the map (see
-- ShadowMap.sprites) so water can decline them: everything the world casts
@@ -838,9 +859,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster))
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
end)
if ViewBox.showsMap(nb) then
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
end)
end
end
for _, p in ipairs(posed) do
local def = p.sprite.def
@@ -865,6 +888,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model))
end
ShadowMap.sprites(false)
-- and the STADIUM models, outside the sprite flag and un-snugged, for
-- the reasons the flat battle pass gives (BattleScene.castShadows):
-- these are geometry, not cut-outs
pcall(function()
local stageArena, stageY = V.require("OverworldBattle").stage()
if stageArena and stageArena.discs then
V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0)
end
V.require("Stadium").cast(ShadowMap)
end)
ShadowMap.finish(sig)
end
@@ -902,8 +935,25 @@ 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
-- the scene shader folds every surface into, in the hour's colour.
-- nil for every map without an entry -- a clear day, exactly as before.
local ForestAtmos = V.require("ForestAtmos")
local atmos = ForestAtmos.frame(state.map)
Voxel3D.fog = atmos and atmos.fog or nil
-- and the DIORAMA modes' viewport and chroma key (lib/Diorama, driven by
-- the headset -- lib/VR sets them for the length of one frame). Both are
-- put back to nil at the end of this function, so no other pass in the
-- frame -- the battle screen's own arena shot above all -- can inherit a
-- cut world or a green background.
local dioFrame = (eyes and Diorama.on) and true or false
Voxel3D.cull = dioFrame and Diorama.cull or nil
Voxel3D.keyColor = dioFrame and Diorama.keyColor() or nil
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
@@ -935,6 +985,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
cx, cy = eyes.cx, eyes.cy
end
-- and the ORBIT RUNGS' own viewport (lib/ViewBox): the flat screen's
-- answer to the same question the diorama's box asks -- the map cut to
-- the window that frames it, so a tilted world reads as a model with
-- sides rather than a map running off every edge. Flat frames only: a
-- headset's cut is Diorama's above, and the two must never both be live.
--
-- After the first-person block, so the box is centred on the camera
-- actually in charge and opens out with a dive into a head rather than
-- vanishing on the frame the rung changed.
--
-- Ahead of castShadows, deliberately: the sun draws the same neighbours
-- the eye does (both ask ViewBox.showsMap), so a map skipped out here is
-- skipped out there and nothing is left casting a shadow it cannot own.
if not eyes then
Voxel3D.cull = ViewBox.frame(cx, cy, vw, vh)
else
ViewBox.stop()
end
-- A staged fight, seen by the VR eyes: the flat screen draws the battle
-- SCREEN while one is up (this pass never runs), but the headset keeps
-- looking at the world, so the world had better have the fight on it.
@@ -965,9 +1034,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
local function drawScene()
Voxel3D.draw(terrain, atlasFor(state.map), nil)
-- the window box's coarse cut, exactly as the sun pass took it: the same
-- test on the same maps, so the light and the eye can never disagree
-- about which neighbours are in this frame (see ViewBox.showsMap)
for i, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
if ViewBox.showsMap(nb) then
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
end
-- Without a shadow map (headless, or a driver that could not make the
@@ -977,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,
@@ -999,7 +1077,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
end
for i, nb in ipairs(state.neighbors or {}) do
if nbWater and nbWater[i] then
if nbWater and nbWater[i] and ViewBox.showsMap(nb) then
waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy) }
end
@@ -1072,6 +1150,24 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
-- and, on the STADIUM rungs, the models -- the same skinned meshes the
-- flat pass and the sun already used this frame, drawn again through
-- THIS eye. Unlike the cards there is nothing per-eye about them: a
-- model faces its opponent, not the viewer, so both eyes see the same
-- pose from their own seats, which is what makes it read as solid.
--
-- On a disc rung the platforms come with them. In a headset the world is
-- still drawn -- the player is standing IN it, which is the whole point
-- of the headset, so the rung's "no map" does not apply here -- and the
-- discs then read as a stage set down on the ground, which is what they
-- are.
pcall(function()
local stageArena, stageY = V.require("OverworldBattle").stage()
if stageArena and stageArena.discs then
V.require("StadiumStage").draw(stageArena, stageY or 0)
end
V.require("Stadium").draw(BattleBillboard.PULL)
end)
if battleTex.flash then Voxel3D.flatten(nil) end
-- and the MOVE ANIMATIONS, standing on the same arena: the
-- engine's own effects layer on the plane through both cells
@@ -1098,11 +1194,30 @@ 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
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
if ViewBox.showsMap(nb) then
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
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
@@ -1119,11 +1234,21 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull, ShadowMap.snug(nil))
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
if ViewBox.showsMap(nb) then
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
end
-- The map's atmosphere -- god rays down from the invisible canopy, and
-- whatever drifts through them (see ForestAtmos). Additive over the
-- finished depth buffer, so the trees occlude the light and the light
-- writes nothing; here in the prop slot, after everything the beams
-- should fall across and inside drawScene so VR gets them per eye. On
-- the one map that has any, today.
ForestAtmos.draw(state.map)
-- The VR pokedex in the player's left hand, last of all: a prop over
-- the world drawn with real depth, so leaning it into a wall still
-- occludes honestly. Its frame only exists while a session is live and
@@ -1157,12 +1282,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
end -- drawScene
-- the viewport fields are this function's for the length of this
-- function, whichever way it leaves (see where they are set)
local function done(result)
Voxel3D.cull, Voxel3D.keyColor = nil, nil
ViewBox.stop()
return result
end
if not eyes then
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
return done(nil)
end
drawScene()
return Voxel3D.endScene()
return done(Voxel3D.endScene())
end
-- The VR frame: the same scene once per eye, each into its own named
@@ -1177,12 +1310,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
if eye.adopt then FirstPerson.adoptVReye(eye.camera) end
if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh,
skyFor(state.map), eye.slot) then
return nil
return done(nil)
end
drawScene()
out[i] = Voxel3D.endScene()
end
return out
return done(out)
end
return VoxelScene
+63 -3
View File
@@ -463,6 +463,28 @@ uniform float pxAngle; // radians of view one screen pixel subtends
// sample to get back to the screen. Declared in both stages, like `vp`, and
// both are highp here.
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
// The viewport, exactly as the scene shader takes it: centre in world
// pixels, then half-size / one-over-fade / kind (0 off, 1 box, 2 ball, 3
// the staged fight's pillar), plus the box's two half-extents. Water is
// world like anything else, and a lake left lying outside the model would
// be the one thing floating in the sky.
uniform vec3 cullAt;
uniform vec3 cullShape;
uniform vec2 cullRect;
float dioramaCull(vec3 p) {
if (cullShape.z <= 0.5) return 1.0;
vec3 cd = p - cullAt;
float inside;
if (cullShape.z < 1.5) {
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
} else if (cullShape.z < 2.5) {
inside = cullShape.x - length(cd);
} else {
inside = cullShape.x - length(cd.xz);
}
return clamp(inside * cullShape.y, 0.0, 1.0);
}
// How far the bend has pushed the world down at world XZ `q` -- the vertex
// stage's own displacement, as a number this stage can add and subtract.
@@ -940,7 +962,16 @@ vec2 waveUV(vec2 tc, vec2 col) {
// can afford it -- the colour is a colour, and tc/sc arrived through
// LOVE's mediump plumbing whatever this signature says -- and the maths
// below runs on the stage default the moment the values touch a local.
vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
//
// Which precision that has to BE is not ours to know: LOVE 12 forward-
// declares effect() under a different one, and pins that matched 11's
// prototype are the mismatch there -- the same refusal, from the other
// side, with the water falling back to flat. So the qualifier is a define
// the Lua side fills in, and Water.shader compiles the pinned form first
// and the bare one only if that is refused. Whichever prototype a runtime
// brought, one of the two agrees with it.
vec4 effect(EFFECT_PREC vec4 color, Image tex, EFFECT_PREC vec2 tc,
EFFECT_PREC vec2 sc) {
// THE DEPTH TEST, done here because the buffer that would have done it is
// detached for the length of this pass so it can be READ (see the header).
// Same comparison, same buffer, same result: a building in front of a pond
@@ -1080,7 +1111,14 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
#ifdef VOXEL_GRID
rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis);
#endif
return vec4(rgb, 1.0) * color;
// and the diorama's rim, over the finished surface. Per FRAGMENT here,
// where the scene shader answers per vertex: this stage already carries
// the world position it marched with, so the exact answer is free --
// and measured on the FLAT world, which is what bendDrop puts back.
float cull = dioramaCull(vec3(vBent.x, vBent.y + bendDrop(vBent.xz),
vBent.z));
if (cull <= 0.0) discard;
return vec4(rgb, cull) * color;
}
#endif
]]
@@ -1133,7 +1171,7 @@ end
Water._trainSource = trainSource -- named for the suite
local function source(grid)
local function source(grid, bare)
local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%")))
src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%")))
local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n"
@@ -1141,6 +1179,12 @@ local function source(grid)
:format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS,
Water.WAVE_STRIDE)
if grid then head = head .. "#define VOXEL_GRID 1\n" end
-- effect()'s parameter precision -- see the signature for why it cannot
-- simply be spelled there. Empty is a define all the same: the params
-- then carry the stage default, which is what a prototype declared
-- without qualifiers wants.
head = head .. (bare and "#define EFFECT_PREC\n"
or "#define EFFECT_PREC mediump\n")
return head .. src
end
@@ -1159,6 +1203,13 @@ function Water.shader(grid)
shaders[grid] = false
else
local ok, sh = pcall(love.graphics.newShader, source(grid))
if not ok then
-- the pinned prototype was the wrong one for this runtime; the bare
-- one is the only other shape there is, and a driver that refuses
-- both was never going to draw this water anyway
local bareOk, bareSh = pcall(love.graphics.newShader, source(grid, true))
if bareOk then ok, sh = bareOk, bareSh end
end
if not ok and V and V.mod and V.mod.log then
-- once, where it can be read: the fallback is flat water, which is
-- easy to look at and impossible to diagnose without this line
@@ -1224,6 +1275,15 @@ function Water.begin(ctx)
send("vp", "row", ctx.vp)
send("eye", ctx.eye)
send("curve", ctx.curve)
-- the viewport, as beginScene sent it to the scene shader; kind 0 --
-- every frame neither the diorama nor the orbit's box has cut -- is
-- "no cut"
local cull = V.require("Voxel3D").cull
send("cullAt", cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 })
send("cullShape", cull and { cull.r, cull.invFade, cull.kind }
or { 0, 0, 0 })
send("cullRect", cull and { cull.rx or cull.r, cull.rz or cull.r }
or { 0, 0 })
send("screen", { ctx.screen[1], ctx.screen[2] })
send("cell", math.max(1, ctx.cell or 1))
-- how much of the view one screen pixel is worth: what sets the relief
+20 -3
View File
@@ -56,11 +56,28 @@ WorldCurve.LABEL = "V-CURVE"
-- of the town -- which stops being a look and starts being an occlusion
-- bug, since what has rolled away is still there to walk into. (The first
-- cut ran 0.18/0.35/0.60 and every rung of it was a marble.)
WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18 }
--
-- 4 AND 5 ARE PAST THAT LINE ON PURPOSE, and they are for the DIORAMA:
-- once the world is a model being looked at from outside rather than a
-- place being walked around in, "the horizon has closed over the next
-- block" stops being a bug and becomes the entire effect -- the town on
-- top of a little planet.
--
-- 5 is the HALF SPHERE, and it is not eyeballed. The drop is a parabola,
-- y = k d^2 with k = amount / vh, and the parabola that osculates a sphere
-- of radius R at its pole is y = d^2 / 2R -- so k = 1 / 2R, and an amount
-- of 1.0 gives R = vh / 2. The diorama's box is cut at exactly half a view
-- height (Diorama.BOX_FRAC), so at amount 1.0 the model's own rim is that
-- sphere's EQUATOR: the ground turns 45 degrees by the edge of the cut and
-- is falling vertically a view-height out. A dome, ending where the model
-- ends. 4 is the step between it and 3, geometrically rather than
-- arithmetically -- the effect goes as the square of distance, so even
-- steps in `amount` would bunch the whole ladder at the bottom.
WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18, 0.42, 1.00 }
WorldCurve.setting = ModSetting.new(WorldCurve.KEY, WorldCurve.LABEL,
{ 0, 1, 2, 3 },
{ "OFF", "1", "2", "3" })
{ 0, 1, 2, 3, 4, 5 },
{ "OFF", "1", "2", "3", "4", "5" })
function WorldCurve.level()
return WorldCurve.setting:get() or 0
+368 -102
View File
@@ -85,16 +85,25 @@ local TiltShift = V.require("TiltShift")
local ChunkMesher = V.require("ChunkMesher")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local ViewBox = V.require("ViewBox")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
local Shiny = V.require("Shiny")
local ShinyBattle = V.require("ShinyBattle")
local ShinyUI = V.require("ShinyUI")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
local Water = V.require("Water")
local ForestAtmos = V.require("ForestAtmos")
local Shadows = V.require("Shadows")
local AntiAlias = V.require("AntiAlias")
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local CamControl = V.require("CamControl")
local VR = V.require("VR")
-- the mod's settings menus: the categories, the screens they open, and the
-- red ink that marks this mod's one row on the engine's OPTIONS list
local SettingsMenu = V.require("SettingsMenu")
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
-- every hook; the other four are the gun, the crowd, the readout and the
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
@@ -102,6 +111,11 @@ local Horde = V.require("Horde")
local HordeGun = V.require("HordeGun")
local HordeHud = V.require("HordeHud")
local HordeSfx = V.require("HordeSfx")
-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the
-- wraps and the experience math; CatchThrow the session (input, arc,
-- ring, choreography); Pokeball the animated prop they throw.
local LetsGo = V.require("LetsGo")
local Pokeball = V.require("Pokeball")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -191,6 +205,22 @@ mod.content.render_pipelines:register("voxel", {
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
-- would mid-walk
DayNight.update(dt)
-- 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.
@@ -200,6 +230,19 @@ mod.content.render_pipelines:register("voxel", {
-- and the whole battle. Ahead of the active() gate below, because a 3D
-- battle does not require the free-roam mode to be switched on.
OverworldBattle.update(dt)
-- The one-time build of the Pokemon Stadium battle models out of the
-- player's own ROM, if there is one to build from and it has not been
-- done (see StadiumInstall). Rides this hook for the same reason the
-- battle does -- it is the tick that runs whatever is on the stack -- and
-- asks exactly once, on the first frame the player is actually in the
-- world, so it is never fighting the engine's own launcher for the
-- screen.
pcall(function() V.require("StadiumScreen").maybePush() end)
-- and a ROM the system file picker dropped in the save directory while
-- we were not the top activity (Android; see StadiumRomPick.poll)
pcall(function()
V.require("StadiumRomPick").poll(require("src.core.Game"))
end)
-- The horde, on the same always-running tick and for the same reason:
-- it owns no pass of the frame, it is a MODE over the overworld, and
-- it has to keep thinking while a warp's wipe covers the screen (the
@@ -289,7 +332,9 @@ mod.content.render_pipelines:register("voxel", {
OverworldBattle.invalidate()
AntiAlias.invalidate()
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,
})
@@ -355,6 +400,11 @@ applyFull = function(level)
-- the horizon flat. The curve bends the world away from a walking player,
-- which fights a fixed diorama framing
WorldCurve.setting:setIndex(1, Game)
-- and the world cut to the window it is framed in (lib/ViewBox). FULL is
-- the model-on-a-table read and the sides are most of what makes it one:
-- a slab of Kanto with edges, rather than a map whose corners happen to
-- fall off the frame.
ViewBox.setting:setIndex(1, Game)
-- and the water reflecting everything it can: FULL is the diorama at its
-- most photographed, and a lake with the sky and the shoreline in it is
-- most of what makes the model read as being outdoors
@@ -405,15 +455,60 @@ local function stagedBattles()
return OverworldBattle.enabled()
end
-- ------- this mod's settings, grouped the way the menus present them
--
-- One entry per setting: the ModSetting itself, the help text the mod
-- manager's page carries, and the fields that decide where it is offered.
--
-- cat which of SettingsMenu's categories the row lives on. The table is
-- kept in category order as well, so the mod manager's own page --
-- which has no categories to give and lists every row flat -- at
-- least keeps related settings next to each other.
-- when a predicate. The row is off the menu entirely while it answers
-- false, because a row that decides nothing reads as a broken mod.
-- full the row SURVIVES the FULL preset. FULL owns the look, so a row
-- goes with it by default; `full` marks the ones that were never
-- about the look. SettingsMenu leans on this and needs no rule of
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
-- category empties out under FULL and takes itself off the menu.
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
-- ------- the top-level menu -- settings that are about the GAME
--
-- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
-- itself rather than inside one of the four categories, which is right
-- here: the categories are the diorama, the fights, what the look costs
-- and the headset, and how often a shiny appears is none of those.
--
-- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
-- an encounter rate is a rule of the game. A player inside FULL must be
-- able to reach it, and FULL must never set it.
{ Shiny.setting,
"How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
.. "rate, and every rung below it is twice as often as the one above.",
cat = SettingsMenu.ROOT, full = true },
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
cat = "world" },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
"Bends the world down over the horizon, until a town sits on top of its "
.. "own little planet.",
cat = "world" },
{ ViewBox.setting,
"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." },
"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
@@ -421,47 +516,61 @@ local SETTINGS = {
-- and forbids back sprites (backPinned answers false), so both rows
-- decide nothing there and a dead switch on the menu reads as broken.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift.",
"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). The diorama becomes a "
.. "tabletop model your head moves around; the 1ST rung stands you "
.. "inside the world at life size, looking where the headset looks. "
.. "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
@@ -470,14 +579,17 @@ local SETTINGS = {
-- device that is not plugged in decides nothing, and this one is read
-- exclusively by the headset's right stick.
{ VR.smoothTurn,
"Turn smoothly with the right stick instead of snapping 45 degrees a "
.. "flick. OFF by default, and deliberately: a software turn moves the "
.. "world past a head that did not move, which is the most reliable way "
.. "to make somebody ill in a headset. Turn it on if you have your sea "
.. "legs and want the continuity.",
when = function() return VR.enabled() end, full = true },
"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
@@ -496,7 +608,7 @@ mod.options:define(schema)
-- 5 V-GRID toggle the wireframe (new)
-- 6 T-SHIFT cycle the blur ladder (was 9)
-- 7 V-CURVE cycle the horizon bend (new)
-- 8 3D-BTL toggle overworld battles (new)
-- 8 3D-BTL cycle overworld battles (new)
-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key)
--
-- Only 6 arrives by the documented route. Game:keypressed answers the
@@ -561,10 +673,29 @@ local function cycleVoxel(game)
return true
end
-- The same, to a NAMED rung rather than one step on: what a diorama mode
-- holds the ladder with, since 2D and both free-roam rungs are things it
-- cannot present (see VR.setVoxelLevel). Everything after the setLevel is
-- the engine work above, for the same reasons.
local function setVoxelLevel(game, level)
local Pipelines = require("src.render.Pipelines")
if Horde.viewLocked() then return false end
if Pipelines.level("voxel") == level then return false end
Pipelines.setLevel("voxel", level)
Pipelines.syncOptions(game.save.options)
game.save.options.tilt = 0
game.save.options.gbcfx = 0
require("src.render.GBCFX").setLevel(0)
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
game:writeOptions()
return true
end
-- The VR stick click makes this same step (VR.stepView): the function is
-- a local of this file, so the handoff is explicit rather than a
-- reimplementation drifting out of date in lib/VR.lua.
VR.cycleVoxel = cycleVoxel
VR.setVoxelLevel = setVoxelLevel
do
local Game = require("src.core.Game")
@@ -635,29 +766,31 @@ do
end
end
-- ------- the mode's rows, kept together
-- ------- the mode's rows, on menus of their own
--
-- The engine splices a pipeline's row in beside TILT, because a display mode
-- belongs with the other display modes; a mod's own ui.options.rows
-- additions land at the END of the list. That left this mod's four rows in
-- two places with unrelated engine rows between them, which reads as two
-- unrelated features rather than one mode with settings.
-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
-- time, so that was four screens of scrolling inside a list that already
-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
-- there past the wireframe and the horizon bend.
--
-- So the plain settings are inserted directly after the last of this mod's
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
-- where the engine already decided display modes go.
local function insertGrouped(out, extra)
local anchor = nil
for i, row in ipairs(out) do
local id = type(row) == "table" and row.id
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
-- Now there is ONE row, and it leads the list. What it opens -- the
-- categories, the screens, and why the split falls where it does -- is
-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
-- display modes, the engine only spliced them beside TILT because it had
-- nowhere better, and TILT is not on the menu any more anyway (see below).
--
-- Two things it takes to move a pipeline row: the engine's descriptor is
-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
-- through its own step function into save.options.pipelines, and rebuilding
-- it here would be a second implementation of something the engine already
-- got right -- and the row is then dropped from the top-level list so it is
-- not in two places at once.
local function captureRow(out, id)
for _, row in ipairs(out) do
if type(row) == "table" and row.id == id then return row end
end
if not anchor then
for _, row in ipairs(extra) do out[#out + 1] = row end
return out
end
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
return out
return nil
end
-- FULL owns the settings that describe the LOOK, so while it is selected those
@@ -727,6 +860,25 @@ local function pinEngineFx(game)
if changed and game.writeOptions then pcall(game.writeOptions, game) end
end
-- ------- the values that follow other values
--
-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
-- that row. Both pins used to be a side effect of the rows hook, which every
-- step on the OPTIONS menu reran -- so they happened whether or not the step
-- was the one that mattered, and nothing had to name them.
--
-- Now a step can happen on the mod's own menu, where no hook runs, or on the
-- mod manager's page, where one never did. So the pinning is a function, and
-- all three routes ask for it.
local function pinDependents(game)
if stagedBattles() then OverworldBattle.forceOG(game) end
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
end
SettingsMenu.setOnChanged(pinDependents)
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
@@ -753,33 +905,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
local full = Voxel.isFull(Pipelines.level("voxel"))
if full then
if Voxel.isFull(Pipelines.level("voxel")) then
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-- horizon bend, the blur, the hour -- so those come off the menu and
-- DAYTIME is held at SYNC while its row is unreachable.
-- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
-- row is unreachable. The rows themselves come off inside SettingsMenu,
-- which is where they live now: T-SHIFT with the wireframe and the bend,
-- and each of them by the same `full` rule rather than by name.
DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do
-- Two things decide whether a row is offered.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
-- applyFull); it does not hold them, so leaving them on the menu is the
-- difference between a preset and a lock.
--
-- And a row whose own switch is off the table this frame (BACK SPRITES,
-- which needs a staged fight to be about) is left off with it. The mod
-- manager's page carries every one of them either way.
local offered = (entry.full or not full)
and (not entry.when or entry.when())
if offered then extra[#extra + 1] = entry[1]:row() end
-- The two pipeline rows move INTO the mod's own root menu: captured as the
-- engine built them, then dropped from here so they are not in two places.
local captured, voxelRow = {}, nil
for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
local row = captureRow(out, id)
-- a pipeline the registry refused is simply not there, and the menu says
-- so by not offering it rather than by offering a hole
if row then captured[#captured + 1] = row end
if id == "pipeline:voxel" then voxelRow = row end
dropRow(out, id)
end
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
@@ -790,14 +959,11 @@ mod.events:on("mod.options_changed", function(payload)
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
-- the OPTIONS row does. The manager persists its own value; this is the one
-- that has to follow it.
if stagedBattles() then OverworldBattle.forceOG() end
-- and DAYTIME changed from the manager's page while FULL owns it snaps
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
-- not, and FULL's pin must hold against both
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
-- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
-- straight back to SYNC -- that row is off the mod's menus under FULL, but
-- the manager's page carries every setting unconditionally, and the pin has
-- to hold against both.
pinDependents()
end)
-- ------- keeping the geometry in step with the world
@@ -878,6 +1044,9 @@ mod.events:on("map.reloaded", function(payload)
if payload and payload.reason == "colors" then return end
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
if mapId then ChunkMesher.invalidate(mapId) end
-- the atmosphere's layout stands on the same carved stamps the meshes
-- do, so it goes stale on exactly the same event
if mapId then ForestAtmos.invalidate(mapId) end
end)
-- ------- rows come and go, so the menu has to notice
@@ -894,30 +1063,55 @@ end)
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
-- clamped rather than reset, so it stays on the row it was just used on
-- instead of jumping to the top when the list below it shortens.
--
-- Held on the INSTANCE rather than compared across one call of update, and
-- that is not a tidying: those three rows live in a SUBMENU now, and the
-- stack only ticks its top state (src/core/StateStack.update). So the step
-- that changes them happens while this menu is suspended and a
-- before/after pair taken around inner() would both be read after the fact
-- and always agree. A signature that outlives the suspension does not.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local OptionRows = require("src.ui.OptionRows")
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
local innerPalettes = OptionsMenu.sgbPalettes
local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil
end
-- What the row LIST depends on: whether FULL is selected (it owns the
-- rows that describe the look), and the two switches that give and take
-- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
-- both battle rows while it is on. Only the FULL-ness of the voxel level
-- matters, so stepping 35 to 50 is not a change.
local function signature()
return string.format("%s|%s|%s",
tostring(Voxel.isFull(Pipelines.level("voxel"))),
tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
end
-- Stamped where the ROWS are built, which is the thing the signature is a
-- signature OF. Read lazily on the first update instead and a menu opened
-- before the change and updated after it would compare the new state
-- against itself and never rebuild.
local innerNew = OptionsMenu.new
function OptionsMenu.new(game, opts)
local menu = innerNew(game, opts)
menu.dramaticShapeSig = signature()
return menu
end
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
-- the VR row hides the two battle rows while it is on, so stepping
-- it changes the LIST exactly the way 3D-BTL does
local hadVR = VR.enabled()
local wasOn = idAt(self, self.index)
local before = self.dramaticShapeSig or signature()
inner(self, dt)
local after = Pipelines.level("voxel")
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles
or VR.enabled() ~= hadVR then
local after = signature()
self.dramaticShapeSig = after
if before ~= after then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
@@ -931,6 +1125,36 @@ do
end
end
-- ------- and the mod's own row is red
--
-- Why this is a palette zone and not love.graphics.setColor -- twice over
-- -- is written out in lib/SettingsMenu.lua, next to the code that builds
-- the palette. The short of it: setColor picks a SHADE on this screen and
-- the zone picks the COLOR.
--
-- Addressed by SLOT, because the row scrolls: it leads the list, so it is
-- normally the top box, but a player who scrolls past it must not leave a
-- red band behind on whatever takes its place. Searched by id rather than
-- assumed to be row 1 for the same reason -- another mod's hook running
-- after ours could put something above it.
function OptionsMenu:sgbPalettes(game)
local zones = innerPalettes and innerPalettes(self, game) or nil
local scroll = self.scroll or 0
for slot = 1, OptionRows.VISIBLE do
local row = self.rows and self.rows[scroll + slot]
if type(row) == "table"
and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
local zone = SettingsMenu.rowZone(game and game.data, slot)
if zone then
zones = zones or {}
zones[#zones + 1] = zone
end
break
end
end
return zones
end
OptionsMenu.dramaticShapeFullHook = true
end
end
@@ -943,6 +1167,39 @@ end
-- so this file keeps naming every engine seam the mod touches.
OverworldBattle.install()
-- ------- shiny Pokemon
--
-- ON, always, with no row to switch it off: shininess is a property of the
-- Pokemon rather than a display mode, and a Pokemon that is shiny in one
-- player's save and not another's is not a Pokemon, it is a setting.
--
-- It rests on a fact the engine already ships. Gen 1 has no shininess of its
-- own, but it has the four DVs Gen 2 reads to decide it, and
-- src/pokemon/Stats.lua:90 carries that reading -- the engine's own comment
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
-- So nothing new is stored on a Pokemon and nothing has to migrate: every
-- save ever made already contains the answer, and this only starts drawing
-- it. See lib/Shiny.lua for why deriving beats storing.
--
-- Three seams, each in its own file with its own reasoning:
-- ShinyBattle wraps Pokemon.new, which is where every wild, gift,
-- starter and traded mon is built, so the roll lands before
-- the sprite is baked
-- ShinyUI the battle pics' tint and the status page's mark
-- ShinyFx the arrival sparkle (armed from Stadium.update)
--
-- The Stadium models need no seam here at all: their recolour happens at
-- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
-- shiny pack.
ShinyBattle.install()
ShinyUI.install()
-- A save opened for the first time under this mod has shiny Pokemon in it
-- already -- they always did -- so refresh the cached flag across the party
-- rather than leaving it absent until each mon next changes.
mod.events:on("save.loaded", function() ShinyBattle.markParty() end)
mod.events:on("save.created", function() ShinyBattle.markParty() end)
-- ------- the free-roam rungs' inputs and their walk
--
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
@@ -1023,6 +1280,15 @@ end
-- become the same eight buttons. See lib/Horde.lua.
Horde.install()
-- ------- LET'S GO capture mode
--
-- After every other input seam on purpose: while a throw is being aimed
-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is
-- read before anything else can claim the pointer -- and outside the aim
-- they forward every byte untouched. The battle-side wraps (throwBall,
-- safariAction) and the experience hooks install here too.
LetsGo.install()
-- ------- edge-anchored menus stay in the GB frame while a headset is live
--
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
+3 -3
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.5.5",
"version": "1.7.2",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -10,11 +10,11 @@
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"conflicts": ["ds_fp_ceiling"],
"permissions": [
"engine_internals"
],
"affects_link": false,
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.",
"description": "Draws the overworld as a 3D diorama.",
"github": "DramaticShape/DramaticShapeVoxelMod"
}
+12 -4
View File
@@ -10,7 +10,9 @@ return {
changed = {
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"with 3D-BTL on 2D-3D A, a battle draws over the map's nearest clear ground instead of over a white field",
"with 3D-BTL on a B rung, the fight is staged on two discs against the sky with no map drawn at all, which works on every map including the caves and shop floors that have nowhere to stage a fight",
"with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- 148 of the 151 species, skinned and animated, playing the animation the move being used actually calls for; Exeggutor, Tangela and Magmar have corrupt animation data at source and keep their battle sprites",
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
@@ -31,10 +33,12 @@ return {
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change",
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"3D-BTL on hotkey 8 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing",
"the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), fainting plays the faint and holds there, and a send-out grows the Pokemon out of the ball and plays the entrance. Damage plays nothing -- the set has no reaction animation in it, and the engine's own flash, blink and HP drain already say so. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
"VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
"VR options row (OFF / STANDARD / DIORAMA / DIORAMA-MR, off by default; a save that stored the old toggle as true comes back on STANDARD). STANDARD is the mode below. DIORAMA is one presentation instead of a ladder: the world is always the model on the table, cut to an invisible BOX centred on the view -- a square slab of world with a HARD edge, because a flat world is a thing with sides -- which V-CURVE turns into a BALL whose rim is a gradient fade into the same sky (the cut reaches terrain, cast, grass, water and the forest's beams alike), with a staged fight ignoring both and cutting a vertical PILLAR about the arena -- always dissolved at the rim -- and framing the model to it: the fight lifted out of the map as a floating disc. The grips take hold of it: one hand carries the model through the room, both hands turn it and open the viewport out. The left stick's click throws V-CURVE to its top rung and back instead of stepping views; there is no 2D diorama and no first-person one, so the VOXEL ladder is held on an orbit rung while the mode runs and the Pokedex stays away. DIORAMA-MR is the same with the background keyed pure green (no bands, no sun, no haze) for a mixed-reality capture",
"VR STANDARD (the row's second rung): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the tabletop the right stick zooms; under STANDARD a squeezed grip drags the table's height, and in a DIORAMA the grips take the model itself -- one carries it, both turn it and resize the viewport, and the left stick's click throws V-CURVE instead; no controller button leaves VR -- that is the VR row's job",
"a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
@@ -49,6 +53,9 @@ return {
"WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water",
"a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line",
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
"the STADIUM rungs need the Pokemon Stadium battle models, and the mod ships none of them -- they are that game's data. Press STADIUM ROM on the OPTIONS menu to pick one with the system file dialog, or drop one in a baseroms/ folder beside the game. It must be Pokemon Stadium (US) 1.0 (md5 ed1378bc12115f71209a77844965ba50) -- every offset in the reader is keyed to that cartridge, and anything else is refused or builds wrong models; either way the 151 models are built out of it on a loading screen, in about ten seconds, into the save directory, and the ROM itself is not kept. Until then the two rungs are simply not on the row. Once built, a rung declines per POKEMON rather than per battle: a species with no pack, a standing substitute doll, and the trainer's own pic before the send-out each fall back to the flat card on that side alone, with the other side keeping its model",
"the STADIUM rungs size a Pokemon by its own model against the set's median, with the range compressed -- the authored heights span sixteenfold, from Caterpie to Gyarados, and a shared over-the-shoulder shot cannot hold that. The order and the feel of the differences survive; the literal ratios do not",
"three species -- Exeggutor, Tangela and Magmar -- have standby loops that are corrupt in the source extraction, and are held at their bind pose so they stand still rather than coming apart",
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
@@ -71,6 +78,7 @@ return {
},
credits = {
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
{ who = "pret/pokestadium", for_ = "the decompilation the STADIUM extractor was written against -- the bone matrix chain and where scale is applied (func_800143C0), the rotation basis (func_8000F730), the animation and texture-animation samplers (func_80016FBC / func_80017540), the battle context slots and the move-id constants. No code or data from it is included or redistributed here; see README.md" },
{ who = "The Khronos Group", for_ = "the OpenXR loader shipped unmodified in assets/vr (Apache-2.0; full license text alongside the DLL)" },
},
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
+181
View File
@@ -0,0 +1,181 @@
# Pokemon Stadium (US) — battle model export
> **Built on [pret/pokestadium](https://github.com/pret/pokestadium).** This
> pipeline is original code, but it could not have been written without that
> project's decompilation: the bone matrix chain and the fact that scale is
> kept out of it (`func_800143C0`), the rotation basis (`func_8000F730`), the
> animation and texture-animation samplers (`func_80016FBC`, `func_80017540`),
> the battle context slots, and the move-id constants all came from reading
> it. **No code or data from that project is vendored here or required to run
> this** — see the mod's [README](../README.md#acknowledgements--pretpokestadium),
> and get anything you want to reuse from upstream under its own terms.
>
> No ROM data is committed either: everything below is generated from a
> cartridge you supply.
All 151 battle Pokemon plus 64 other models from the same segment, in standard
formats. Regenerate straight from the ROM — stdlib only, no `make init`, no
splat, no crunch64:
```bash
model_extract/pipeline/build.py
```
Put a US 1.0 ROM in [baseroms/](baseroms/) (`.z64`, `.n64` or `.v64`), or pass
`--rom=PATH`. See [pipeline/README.md](pipeline/README.md) for the module layout,
how the ROM is unpacked, and the generated-effects notes.
```
viewer.html browse everything in the browser — open it directly
manifest.json every model + what each of its animations is used for
moves.json all 165 moves + the animation each species plays for them
glb/025_pikachu.glb glTF 2.0 binary: mesh, skeleton, skin, animations, textures
glb/x152_model.glb non-Pokemon models from the same segment (props, trophies…)
textures/025_pikachu/ the same textures as loose PNGs, named <n>_<w>x<h>.png
js/ viewer payloads, one per model, plus index.js and moves.js
```
`viewer.html` has a filterable picker for every model plus prev/next/random, a
**move picker** that jumps to whichever animation the current Pokemon plays for
that move, per-animation playback with a frame scrubber, an eye/texture-animation
selector, and texture/lighting/wireframe/skeleton toggles. It reads `js/`, not
`glb/`, because browsers block `fetch` of local files from `file://` — script
injection is what lets the page work when you just double-click it.
Sizes: 73 MB `glb/`, 54 MB `js/`, 7.4 MB `textures/`. Two flags if you want less:
`--no-js` skips the viewer payloads and `viewer.html` (leaving the glTF export
alone), and `--manifest-only` rebuilds just `manifest.json`.
`.glb` files are self-contained and open directly in Blender, Maya, Unity, Unreal,
three.js, Godot, Windows 3D Viewer, macOS Quick Look, and https://gltf-viewer.donmccurdy.com.
Source file `N.bin` holds species `N + 1`.
## Conventions
- Y up, +Z front, right-handed — glTF standard.
- Animations are authored at **30 fps**; keyframe times are `frame / 30`.
- Units are game units. Models are authored 10x and scaled down by the
`model_root` node, matching the geo layout's scale command.
- Textures are `CLAMP_TO_EDGE`, materials are `alphaMode: MASK` with cutoff 0.5
(N64 RGBA5551 has one bit of alpha). `doubleSided` follows the display list's
cull mode.
## Skeleton
The game keeps bone scale *out* of the matrix chain (`func_800143C0` in
[src/12D80.c](../src/12D80.c)): scale accumulates in its own stack, a bone's local
translation is pre-multiplied by the parent's accumulated scale, and the
accumulated scale is applied to the rows of the finished world matrix only at
draw time. glTF node TRS instead propagates scale multiplicatively to children,
so a 1:1 node mapping would be wrong wherever a non-uniformly scaled bone has
descendants.
Each game bone is therefore exported as two nodes:
| node | role |
| --- | --- |
| `boneNN` | pivot: `translation = t * accScale(parent)`, `rotation = R`, scale 1 |
| `boneNN_scale` | leaf child holding `scale = accScale(bone)`, so it cannot propagate |
The skin binds to the `boneNN_scale` nodes, whose world matrices then equal the
game's draw matrices exactly. Vertices are already in bone-local space and each is
rigidly bound to one bone, so all inverse bind matrices are identity.
This was verified by parsing each exported `.glb` back and diffing every joint
matrix against a reference implementation of the game's own math, over the bind
pose and four sampled frames of every animation. Worst-case disagreement is
~1e-2 game units on models spanning 2040 units, entirely from storing rotations
as spec-normalised `SHORT` quaternions.
## Animation semantics
Per-species battle data lives in `assets/us/70D3A0.bin`, 0xB90 bytes per species,
DMA'd into the battle system by `func_84302658` via the `D_80075BD0[species-1]`
pointer table. It is an array of 0x10-byte entries; byte 0 of each entry is an
index into that Pokemon's animation list and byte 1 indexes the auxiliary list:
- **entries 0164** — one per move, in the move ID order of
[oldnotes/stadium1/constants/move_constants.s](../oldnotes/stadium1/constants/move_constants.s).
Entry *n* gives the animation played when the Pokemon uses move *n + 1*.
- **entries 165+** — fixed battle contexts (idle, hit, faint, …).
Every one of the 151 species' tables indexes only animations that exist in that
species' list, which is what confirms the layout.
`manifest.json` reports, for each animation, the exact list of moves that trigger
it plus which context slots reference it. `moves.json` inverts that: every move,
and the animation each of the 151 species plays for it.
One caveat when reading move data: the table is **dense**. Every species has a
row for every move, including moves it can never learn, and those unreachable
rows overwhelmingly point at the species' generic reaction animation — the same
one the `hit` slot uses. So "118 species play Thunderbolt" is an artifact, not a
fact about the game. `moves.json` marks each row with `differsFromDefault` and
gives a `speciesWithOwnAnimation` count per move; treat those as the signal. The `animationSlots` section carries an
`evidence` field per slot:
- `code` — the battle code in `src/fragments/62` names the slot outright.
- `data` — inferred from what the referenced animation actually does, measured
across all 151 species. For example slot 167 is labelled `faint` because its
animation always ends far from the standing pose (the model drops to
0.030.84x idle height, or leaves the frame entirely for fliers), while slot
168's animation always ends at exactly idle height.
`endBehavior` reports what the animation player does past the last frame
(`func_80016FBC`): every animation in the game wraps back to `loopStartFrame`, so
one-shots like `faint` are ended by the battle state machine switching animation,
not by the player clamping. glTF has no loop flag, so importers will loop clips by
default.
The common layout, consistent across nearly every species:
| animation | role |
| --- | --- |
| 0 | idle / standby loop (all 151 species) |
| 1 | second idle-length animation, rarely referenced by the context slots |
| 2 | hit / damage reaction (149151 species across slots 166, 178181) |
| 3 … n-3 | attack animations, selected per move |
| n-2 | faint (slots 167, 177) |
| n-1 | entrance / return-to-idle cycle (slots 168, 183) |
## Texture animations (blinking, dizzy eyes)
The second animation list in the model root is a *texture* animation, not a
skeletal one (`src/18140.c`). Geo command `0x23` carries a channel index at
offset `0x02`; when it is `>= 0`, `func_800176DC` replaces that material's
texture every frame from a per-frame stream of texture-table indices.
Charmander's eyes are the clearest example — texture 2 is the open eye, 3 and 4
are blink frames, and 57 are the dizzy swirl:
| aux animation | frames | texture stream |
| --- | --- | --- |
| 0 | 10 | `2 2 3 3 4 4 4 3 3 2` — a blink |
| 2 | 122 | cycles `5 6 7` — confusion swirl |
| 4 | 18 | a slower blink |
The viewer plays these. Each skeletal animation is paired with the texture
animation the battle table most often sets alongside it, and the `eyes` dropdown
overrides that. glTF 2.0 has no texture-swap animation channel, so this data
lives in `js/` and `moves.json` rather than the `.glb` — the `.glb` files carry
the first frame's texture on each material.
## Known gaps
- **Move effect visuals are not here.** Geo command `0x24` does not draw
anything: `func_80014CB8` just records an attachment point (an id plus a world
position) on the Pokemon, and the battle system spawns particles there. Ids
114 are generic and used by nearly every species. So Charmander's tail flame,
beams, explosions and the like are drawn by the effect system in the battle
fragments and are not present in these model files — Charmander's texture set
contains eyes, claws, teeth and skin, and no flame.
- **The Poke Ball throw/open model was not found.** It is not in this segment,
no other `assets/us/**.bin` contains a model fragment, and scanning the ROM
ranges of fragments 6264 for embedded model headers found none. What does
exist is Poke Ball *2D* artwork in fragment 29
(`fragments/29/fragment29_unk_bin_*`, flagged in the splat yaml). The throw is
most likely built from raw display lists rather than a geo-layout model.
- Files 151214 are exported as `x<file>_model` with generic names. They are
props, trophies, minigame pieces and similar; only Surfing Pikachu (file 152,
the same 37-bone / 723-triangle rig as Pikachu) is named with confidence. They
carry no battle table, so their animations are left unnamed.
+5
View File
@@ -0,0 +1,5 @@
baserom.z64
*.z64
*.n64
*.v64
*:Zone.Identifier
+25
View File
@@ -0,0 +1,25 @@
# Put the ROM here
`pipeline/build.py` looks for a Pokemon Stadium (US 1.0) ROM in this folder:
model_extract/baseroms/baserom.z64
`.z64`, `.n64` and `.v64` byte orders are all accepted — the pipeline detects the
magic and normalises on load. Any ROM file dropped in this folder is picked up.
Expected md5 of the US 1.0 ROM: `ed1378bc12115f71209a77844965ba50`. A different
ROM still runs, but the build prints a warning since the offsets are keyed to
this revision.
Search order (first hit wins):
1. `model_extract/baseroms/baserom.z64`
2. `model_extract/baseroms/us/baserom.z64`
3. `baseroms/us/baserom.z64` at the repo root — the location `make init` uses
4. any `*.z64` / `*.n64` / `*.v64` in this folder
Or point at one explicitly:
model_extract/pipeline/build.py --rom=/path/to/baserom.z64
The ROM is not included and is not tracked by git.
+93
View File
@@ -0,0 +1,93 @@
# Export pipeline
End-to-end: `baserom.z64` in, everything in `model_extract/` out. **Stdlib only**
no `make init`, no splat, no crunch64, no build directory.
```bash
model_extract/pipeline/build.py # finds the ROM automatically
model_extract/pipeline/build.py --rom=/path/to.z64 --out=/tmp/out
```
The ROM is looked for in `model_extract/baseroms/` first, so this folder stands
on its own; the repo's own `baseroms/us/` is the fallback. Search order:
1. `model_extract/baseroms/baserom.z64`
2. `model_extract/baseroms/us/baserom.z64`
3. `baseroms/us/baserom.z64` at the repo root — where `make init` expects it
4. any `*.z64` / `*.n64` / `*.v64` sitting in `model_extract/baseroms/`
`.z64`, `.n64` and `.v64` all work — the byte order is detected from the magic
and normalised on load. See [../baseroms/README.md](../baseroms/README.md).
| flag | effect |
| --- | --- |
| `--only=3,91` | restrict to those model file numbers (fast iteration) |
| `--no-glb` | skip the glTF binaries and PNG dumps |
| `--no-js` | skip the viewer payloads and `viewer.html` |
| `--no-effects` | skip the generated fire/gas stand-ins |
## Modules
| file | does |
| --- | --- |
| `rom.py` | byte-order fixup (.z64/.v64/.n64), md5 check, archive unpacking, Yay0 and PERS-SZP decompression |
| `fragment.py` | FRAGMENT module → geo layout walk, F3DEX2 execution, textures, skeleton, animations |
| `battle.py` | per-species battle tables, move names, animation-context slot meanings |
| `glb.py` | glTF 2.0 binary writer |
| `effects.py` | **generated** fire/gas stand-ins (see below) |
| `build.py` | driver: ties it together, writes manifests |
## Getting from ROM to models without the build system
Three steps, all in `rom.py`:
1. **Byte order.** `.z64` is native; `.v64` swaps byte pairs; `.n64` reverses
words. Detected from the magic and normalised on load.
2. **Archive.** The segment at `0x920000` starts with
`u32 tag, u32 0, u32 totalSize, u32 fileCount`, then one
`{u32 offset, u32 size, u32 pad[2]}` record per file. Only the top three
bytes of the first word are reliably zero — the model archive puts a nonzero
value in the low byte, which is the quirk `tools/unpack_asset.py` works
around too.
3. **Decompression.** Each entry is `PERS-SZP` (an 8-byte magic plus a header
size, wrapping a Yay0 stream). The Yay0 decoder is ~30 lines: a bitstream
where a 1 copies a literal byte and a 0 pulls a (distance, length) pair.
Verified by decompressing all 215 entries and diffing against what
`make init` produces: **215/215 byte-identical**.
The battle tables need one more hop — `D_80075BD0[species - 1]` lives in the main
code segment, so `Rom.vram_to_rom` converts `0x80075BD0` using the segment's
`start`/`vram` from the splat yaml.
## Generated effects
`effects.py` produces **original, procedurally generated** fire and gas. It is
not extracted game data, and everything it emits is tagged `generated: true` in
the manifest, the viewer payloads and the PNG filenames (`*_fx.png`).
This exists because the real effects are not in the model files at all. Geo
command `0x08` attaches a callback (`func_80014A60` calls `node->unk_10`), and
the model supplies only two empty display lists and zeroed scratch buffers for
it to fill. The callback lives in another fragment and has not been ported, so
there is no flame mesh or flame texture to extract — Charmander's texture set is
eyes, claws, teeth and skin.
The stand-ins are anchored to the exact bone the callback hangs off, so they sit
where the real effect would and follow the animation:
| callback | species | stand-in |
| --- | --- | --- |
| `0x810000D8` | Charmander, Charmeleon, Charizard, Magmar, Moltres | tail/crest flame |
| `0x81000108` | Ponyta, Rapidash, Moltres wings | small flame |
| `0x810000E0` | Gastly (only) | gas cloud |
Both are looping flipbooks built from tileable value noise, drawn on a pair of
crossed quads — glTF cannot billboard, so crossed quads are the portable way to
make them read from any angle. Sizes are expressed as a fraction of the model's
**height** and divided by the anchor bone's accumulated scale, so an effect comes
out the intended size wherever in the skeleton it hangs. Seeds derive from the
species number, so a given Pokemon always generates the same effect.
In the viewer they draw in a second pass with depth writes off — additive for
fire, alpha for gas — and there is a *generated effects* toggle to hide them.
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Per-species battle data.
`func_84302658` in src/fragments/62 DMAs a 0xB90-byte table per species out of
the 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. It is an
array of 0x10-byte entries: byte 0 is an index into that Pokemon's animation
list, byte 1 indexes the auxiliary (texture) animation list.
entries 0..164 one per move, so entry n drives move n + 1
entries 165+ fixed battle contexts
Every one of the 151 species' tables indexes only animations that species
actually has, which is what confirms the layout.
"""
import os
import struct
STRIDE = 0xB90
ENTRY = 0x10
N_MOVES = 165
# `evidence` records how far each label can be trusted:
# code - the battle code in src/fragments/62 names the slot outright
# data - inferred from what the referenced animation does, measured over all
# 151 species
CONTEXT_SLOTS = {
165: ('idle', 'code',
'The standby loop. func_8432B0A4 restores this slot whenever the Pokemon '
'returns to neutral, and it resolves to animation 0 for all 151 species.'),
166: ('attack_default', 'data',
'Resolves to animation 2 for 149/151 species, the same animation slots '
'178-181 use in the reaction paths. Called "hit" until the move table '
'was read against it: it is the animation most of a species\' MOVES '
'play, the default attack rather than a damage reaction (the name has '
'to match lib/StadiumPack.lua\'s CONTEXT and StadiumBuild\'s CONTEXTS).'),
167: ('faint', 'data',
'The referenced animation always ends far from the standing pose - the '
'model collapses to 0.03-0.84x its idle height, or leaves the frame '
'entirely for fliers.'),
168: ('entrance', 'code+data',
'The default slot in func_8430506C / func_8432AF70. The referenced '
'animation ends at exactly idle height, so it is a full cycle that '
'settles back into the standby pose.'),
169: ('reaction_169', 'data', 'Resolves to the idle animation for 139/151 species.'),
170: ('reaction_170', 'data', 'Split between the idle and hit animations.'),
171: ('reaction_171', 'data', 'Resolves to the idle animation for 138/151 species.'),
172: ('reaction_172', 'data', 'Resolves to the idle animation for 148/151 species.'),
173: ('reaction_173', 'data', 'Resolves to the hit animation for 97/151 species.'),
174: ('reaction_174', 'data', 'Resolves to the idle animation for 138/151 species.'),
175: ('struggle', 'code', 'Passed as slot 0xAF to func_84305A74.'),
176: ('idle_alt', 'code',
'Substituted for the idle slot when battle flag 0x200 is set.'),
177: ('faint_alt', 'data', 'Same animation as slot 167 for almost every species.'),
178: ('flinch', 'code',
'Used when the incoming move is one of the two listed in D_84384598.'),
179: ('reaction_179', 'data', 'Resolves to the hit animation for all 151 species.'),
180: ('reaction_180', 'data', 'Resolves to the hit animation for all 151 species.'),
181: ('reaction_181', 'data', 'Resolves to the hit animation for all 151 species.'),
182: ('reaction_182', 'data',
'Resolves to animation 0 for 136 species and animation 1 for the other 15.'),
183: ('entrance_alt', 'data', 'Same animation as slot 168 for every species.'),
184: ('idle_return', 'code', 'Passed as slot 0xB8 to func_84305A74.'),
}
MOVE_CONSTANTS = 'oldnotes/stadium1/constants/move_constants.s'
def load_move_names(repo_root='.'):
"""Move IDs come from the repo's own extracted constants when available."""
path = os.path.join(repo_root, MOVE_CONSTANTS)
names = {}
if os.path.exists(path):
for line in open(path, encoding='utf-8', errors='replace'):
# the file also carries an ABC_* section indexing moves alphabetically
# by their Japanese names; only the real move IDs are wanted
if ' EQU ' not in line or line.startswith('ABC_'):
continue
name, val = line.split(' EQU ')
val = val.split(';', 1)[0].strip()
if val:
names[int(val, 0)] = name.strip().replace('_', ' ').title()
return {i: names.get(i, f'Move {i}') for i in range(1, N_MOVES + 1)}
class BattleTables:
def __init__(self, rom):
from rom import BATTLE_DATA, PTR_TABLE_VRAM
self.rom = rom
self.base = BATTLE_DATA
self.ptr_table = rom.vram_to_rom(PTR_TABLE_VRAM)
def offset(self, species):
raw = self.rom.u32(self.ptr_table + (species - 1) * 4)
return self.base + (raw & 0xFFFFFF)
def rows(self, species):
"""Returns [(animIndex, auxIndex)] for every entry, aux 0xFF -> -1."""
o = self.offset(species)
out = []
for e in range(STRIDE // ENTRY):
anim = self.rom.data[o + e * ENTRY]
aux = self.rom.data[o + e * ENTRY + 1]
out.append((anim, -1 if aux == 0xFF else aux))
return out
SPECIES = {}
_NAMES = (
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
for _i, _n in enumerate(_NAMES):
SPECIES[_i + 1] = _n
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""
End-to-end export: baserom.z64 -> glb / textures / viewer payloads / manifests.
model_extract/pipeline/build.py [--rom PATH] [--out DIR] [options]
Stdlib only. Nothing here needs `make init`, splat or crunch64 -- the ROM is
read, decompressed and parsed directly.
Options:
--no-js skip the viewer payloads and viewer.html
--no-glb skip the glTF binaries and PNG dumps
--no-effects skip the generated fire/gas stand-ins
--only N[,N] restrict to these model file numbers (for quick iteration)
"""
import base64
import collections
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
REPO = os.path.abspath(os.path.join(HERE, '..', '..'))
import battle
import effects as fx_gen
import fragment
import glb as glb_mod
import rom as rom_mod
N_POKEMON = 151
EXTRA_NAMES = {152: 'Surfing Pikachu'} # only the one identified with confidence
# Where to look for the ROM, in order. model_extract/baseroms/ comes first so the
# folder can stand on its own; the repo's own baseroms/ is the fallback.
BASEROMS = os.path.join(os.path.dirname(HERE), 'baseroms')
ROM_CANDIDATES = [
os.path.join(BASEROMS, 'baserom.z64'),
os.path.join(BASEROMS, 'us', 'baserom.z64'),
os.path.join(REPO, 'baseroms', 'us', 'baserom.z64'),
]
def find_rom():
for p in ROM_CANDIDATES:
if os.path.exists(p):
return p
if os.path.isdir(BASEROMS): # any ROM dropped in the folder
for f in sorted(os.listdir(BASEROMS)):
if f.lower().endswith(('.z64', '.n64', '.v64')):
return os.path.join(BASEROMS, f)
return None
# --------------------------------------------------------------- bind extent
def bind_extent(data):
"""World-space bounding box of the bind pose, used to size the effects."""
def trs(t, r, s):
S = lambda v: math.sin(v / 32768 * math.pi)
C = lambda v: math.cos(v / 32768 * math.pi)
sx, cx = S(r[0]), C(r[0]); sy, cy = S(r[1]), C(r[1]); sz, cz = S(r[2]), C(r[2])
return [cy*cz*s[0], cy*sz*s[0], -sy*s[0], 0,
(sx*sy*cz-cx*sz)*s[1], (sx*sy*sz+cx*cz)*s[1], sx*cy*s[1], 0,
(cx*sy*cz+sx*sz)*s[2], (cx*sy*sz-sx*cz)*s[2], cx*cy*s[2], 0,
t[0], t[1], t[2], 1]
def mul(a, b):
r = [0]*16
for c in range(4):
for i in range(4):
r[c*4+i] = a[i]*b[c*4] + a[4+i]*b[c*4+1] + a[8+i]*b[c*4+2] + a[12+i]*b[c*4+3]
return r
root = trs([0, 0, 0], [0, 0, 0], data['rootScale'])
acc, uns, mats = [], [], []
for b in data['bones']:
pa = acc[b['parent']] if b['parent'] >= 0 else [1.0, 1.0, 1.0]
pu = uns[b['parent']] if b['parent'] >= 0 else root
u = mul(pu, trs([b['t'][k]*pa[k] for k in range(3)], b['r'], [1, 1, 1]))
a = [pa[k]*b['s'][k] for k in range(3)]
m = list(u)
for k in range(4):
m[k] *= a[0]; m[4+k] *= a[1]; m[8+k] *= a[2]
acc.append(a); uns.append(u); mats.append(m)
lo = [1e9]*3; hi = [-1e9]*3
for p in data['prims']:
for i, bi in enumerate(p['skin']):
m = mats[bi]
x, y, z = p['pos'][i*3:i*3+3]
w = (m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13],
m[2]*x+m[6]*y+m[10]*z+m[14])
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
# height, not the largest dimension: sizing off the max would scale Moltres'
# flames to its wingspan
extent = (hi[1] - lo[1]) if lo[0] <= hi[0] else 1.0
# how much each bone scales its own local space, so effects can compensate
scales = [math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]) for m in mats]
return extent, scales
# ------------------------------------------------------------ animation names
# Which context name wins when several claim the same animation. The battle
# table points many slots at one clip, and these are the ones worth naming.
NAME_PREF = ['idle', 'attack_default', 'faint', 'entrance', 'struggle', 'flinch']
def label_animations(data, rows, moves):
"""Name each animation after what the battle table uses it for, and pair it
with the texture animation that table most often sets alongside it.
Mutates `data['anims']`, giving each a `name` and an `aux`, and hands back
the per-animation context and move lists the manifest reports. Factored out
of main() because the mod's packer (tools/stadium_pack.py) has to label them
exactly the same way for its output to be comparable with the Lua extractor
that reads the same ROM at runtime.
"""
entries = [r[0] for r in rows]
aux = [r[1] for r in rows]
uses = [[] for _ in data['anims']]
move_uses = [[] for _ in data['anims']]
for e, ai in enumerate(entries):
if ai >= len(uses):
continue
if e < battle.N_MOVES:
move_uses[ai].append(moves[e + 1])
elif e in battle.CONTEXT_SLOTS:
uses[ai].append(battle.CONTEXT_SLOTS[e][0])
pairs = [collections.Counter() for _ in data['anims']]
for e, ai in enumerate(entries):
if ai < len(pairs) and 0 <= aux[e] < len(data['auxAnims']):
pairs[ai][aux[e]] += 1
for i, a in enumerate(data['anims']):
ctx = sorted(set(uses[i]))
named = [n for n in NAME_PREF if n in ctx]
a['name'] = (named[0] if named else 'attack' if move_uses[i]
else ctx[0] if ctx else f'anim{i}')
a['aux'] = pairs[i].most_common(1)[0][0] if pairs[i] else -1
seen = {}
for a in data['anims']:
n = seen.get(a['name'], 0)
seen[a['name']] = n + 1
if n:
a['name'] = f'{a["name"]}_{n + 1}'
return uses, move_uses
def attach_effects(data, species, raw=False):
"""Append generated fire/gas prims + their flipbook textures.
`raw` matches fragment.extract's: the flipbook frames are already RGBA8, so
they are stored as-is rather than encoded, for the packer that wants pixels.
"""
if not data.get('fx'):
return 0
extent, bone_scale = bind_extent(data)
made = fx_gen.build_for(species, data['fx'], extent, bone_scale)
for e in made:
first = len(data['textures'])
for i, frame in enumerate(e['frames']):
rec = dict(index=-1, w=e['w'], h=e['h'], generated=True)
if raw:
rec['rgba'] = frame
else:
rec['png'] = ('data:image/png;base64,' + base64.b64encode(
fragment.png(e['w'], e['h'], frame)).decode())
data['textures'].append(rec)
g = e['geo']
data['prims'].append(dict(
tex=first, cull=0, texAnim=-1, texMap={},
generated=True, effect=e['kind'],
blend='add' if e['kind'] == 'fire' else 'alpha',
fxFrames=list(range(first, first + len(e['frames']))),
pos=g['pos'], uv=g['uv'], nrm=g['nrm'], skin=g['skin'], idx=g['idx']))
return len(made)
# --------------------------------------------------------------------- main
def main(argv):
args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) for a in argv}
rom_path = args.get('--rom') or find_rom()
outdir = args.get('--out') or os.path.join(REPO, 'model_extract')
want_js = '--no-js' not in args
want_glb = '--no-glb' not in args
want_fx = '--no-effects' not in args
only = {int(x) for x in args['--only'].split(',')} if '--only' in args else None
if not rom_path or not os.path.exists(rom_path):
sys.exit('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n'
f' {os.path.join(BASEROMS, "baserom.z64")}\n'
'or pass --rom=PATH. Searched:\n '
+ '\n '.join(ROM_CANDIDATES))
print(f'reading {rom_path}')
rom = rom_mod.Rom(rom_path)
print(f' md5 {rom.md5}' + ('' if rom.is_expected_us else ' (NOT the expected US 1.0 ROM)'))
blobs = rom_mod.pokemon_models(rom)
print(f' {len(blobs)} model fragments')
tables = battle.BattleTables(rom)
moves = battle.load_move_names(REPO)
for sub in ('glb', 'textures', 'js'):
os.makedirs(os.path.join(outdir, sub), exist_ok=True)
manifest = dict(
source='Pokemon Stadium (US) 1.0', romMd5=rom.md5,
generator='model_extract/pipeline/build.py',
coordinateSystem='Y up, +Z front, units are game units (models authored 10x, '
'baked into the model_root node scale)',
frameRate=30,
generatedEffects='Prims and textures tagged generated:true are NOT extracted '
'game data -- see pipeline/effects.py.',
animationSlots={str(k): dict(name=v[0], evidence=v[1], description=v[2])
for k, v in battle.CONTEXT_SLOTS.items()},
pokemon=[], extra=[])
move_rows, anim_names, index, fx_count = {}, {}, [], 0
for fileno, blob in enumerate(blobs):
if only is not None and fileno not in only:
continue
try:
data = fragment.extract(blob, f'{fileno}.bin')
except Exception as exc:
print(f' skip {fileno}: {exc}')
continue
species = data['species']
pokemon = fileno < N_POKEMON
if pokemon:
rows = tables.rows(species)
uses, move_uses = label_animations(data, rows, moves)
slug = f'{species:03d}_{battle.SPECIES.get(species, str(species)).lower()}'
data['name'] = battle.SPECIES.get(species, f'#{species}')
move_rows[species] = [[rows[e][0], rows[e][1]]
for e in range(battle.N_MOVES)]
anim_names[species] = [a['name'] for a in data['anims']]
else:
slug = f'x{fileno:03d}_model'
data['name'] = EXTRA_NAMES.get(fileno, f'Model {fileno}')
for i, a in enumerate(data['anims']):
a['name'] = f'anim{i}'
a['aux'] = 0 if data['auxAnims'] else -1
nfx = attach_effects(data, species) if want_fx else 0
fx_count += nfx
pngs = [base64.b64decode(t['png'].split(',', 1)[1]) for t in data['textures']]
if want_glb:
with open(os.path.join(outdir, 'glb', slug + '.glb'), 'wb') as fp:
fp.write(glb_mod.build_glb(data, pngs))
texdir = os.path.join(outdir, 'textures', slug)
os.makedirs(texdir, exist_ok=True)
for i, (t, p) in enumerate(zip(data['textures'], pngs)):
tag = '_fx' if t.get('generated') else ''
with open(os.path.join(texdir, f'{i:02d}_{t["w"]}x{t["h"]}{tag}.png'), 'wb') as fp:
fp.write(p)
if want_js:
with open(os.path.join(outdir, 'js', slug + '.js'), 'w') as fp:
fp.write('PKMN_LOAD(' + json.dumps(data, separators=(',', ':')) + ');\n')
entry = dict(
species=species, name=data['name'], slug=slug,
group='pokemon' if pokemon else 'extra',
sourceFile=f'{fileno}.bin', glb=f'glb/{slug}.glb',
textureDir=f'textures/{slug}',
triangles=sum(len(p['idx']) // 3 for p in data['prims']),
vertices=sum(len(p['pos']) // 3 for p in data['prims']),
bones=len(data['bones']), textures=len(data['textures']),
generatedEffects=nfx,
animations=[dict(
index=i, name=a['name'], frames=a['frames'],
seconds=round(a['frames'] / 30.0, 3),
endBehavior='clamp' if (a['flags'] & 2) else 'wrap',
loopStartFrame=a['loopStart'],
**(dict(contexts=sorted(set(uses[i])),
moves=sorted(set(move_uses[i])),
moveCount=len(set(move_uses[i]))) if pokemon else {}))
for i, a in enumerate(data['anims'])])
(manifest['pokemon'] if pokemon else manifest['extra']).append(entry)
index.append(dict(species=species, name=data['name'], slug=slug,
group=entry['group'], triangles=entry['triangles'],
bones=entry['bones'], animations=len(data['anims'])))
print(f' {slug:<22} {len(data["anims"]):2d} anims {entry["triangles"]:5d} tris'
+ (f' +{nfx} effect' if nfx else ''))
# ---- move index ------------------------------------------------------
moves_out = []
if move_rows:
default_anim = {p['species']: next(
(a['index'] for a in p['animations'] if 'attack_default' in a.get('contexts', [])), -1)
for p in manifest['pokemon']}
for mid in range(1, battle.N_MOVES + 1):
users, tally, ndiff = [], collections.Counter(), 0
for sp in sorted(move_rows):
ai, ax = move_rows[sp][mid - 1]
name = anim_names[sp][ai] if ai < len(anim_names[sp]) else f'anim{ai}'
diff = ai != default_anim.get(sp, -1)
ndiff += diff
tally[name] += 1
users.append(dict(species=sp, animation=ai, animationName=name,
aux=ax, differsFromDefault=diff))
moves_out.append(dict(
id=mid, name=moves[mid], speciesWithOwnAnimation=ndiff,
animationNames=[dict(name=n, species=c) for n, c in tally.most_common()],
users=users))
with open(os.path.join(outdir, 'moves.json'), 'w') as fp:
json.dump(dict(source=manifest['source'], note=(
'Entry n of the per-species battle table (0-indexed) selects the '
'animation played when that Pokemon uses move n+1. The table is dense '
'- every species has a row for every move, including moves it can '
'never learn - and those unreachable rows overwhelmingly point at the '
'species\' generic reaction animation. Use differsFromDefault.'),
moves=moves_out), fp, indent=1)
with open(os.path.join(outdir, 'manifest.json'), 'w') as fp:
json.dump(manifest, fp, indent=2)
if want_js:
with open(os.path.join(outdir, 'js', 'index.js'), 'w') as fp:
fp.write('window.PKMN_INDEX = ' + json.dumps(index, separators=(',', ':')) + ';\n')
if moves_out:
with open(os.path.join(outdir, 'js', 'moves.js'), 'w') as fp:
fp.write('window.PKMN_MOVES = ' + json.dumps(
[dict(id=m['id'], name=m['name'],
bySpecies={str(u['species']): [u['animation'], u['aux']]
for u in m['users']}) for m in moves_out],
separators=(',', ':')) + ';\n')
viewer_src = os.path.join(REPO, 'tools/model_viewer/viewer.html')
if os.path.exists(viewer_src):
with open(viewer_src) as s, open(os.path.join(outdir, 'viewer.html'), 'w') as d:
d.write(s.read())
print(f'\n{len(manifest["pokemon"])} Pokemon + {len(manifest["extra"])} other models'
f', {fx_count} generated effects')
if __name__ == '__main__':
main(sys.argv[1:])
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Generated stand-in effects.
IMPORTANT: nothing in this file is extracted game data. The real tail flame,
mane fire and gas are drawn by procedural callbacks that live in another
fragment (geo command 0x08 -> func_80014A60 calls node->unk_10, and the model
file supplies only two empty display lists plus zeroed scratch buffers). Those
callbacks have not been ported, so the models genuinely contain no flame mesh
and no flame texture.
What follows is an original, procedurally generated replacement: looping
flipbook textures plus a pair of crossed quads anchored to the bone the
callback hangs off. It is meant to make the models look right in the viewer,
and it is tagged `generated: true` everywhere it appears so it is never
mistaken for ripped content.
"""
import math
# geo cmd 0x08 callback ids -> which effect to stand in for. The grouping is the
# game's own: every species sharing a callback shares an effect.
FIRE_TAIL = 0x810000D8 # Charmander, Charmeleon, Charizard, Magmar, Moltres
FIRE_SMALL = 0x81000108 # Ponyta, Rapidash, Moltres wings
AURA = 0x810000E0 # Gastly, Koffing, Weezing, Vaporeon, Articuno, Moltres
class Rng:
"""Deterministic PRNG so a given species always generates the same effect."""
def __init__(self, seed):
self.s = seed & 0xFFFFFFFF or 0x9E3779B9
def next(self):
x = self.s
x ^= (x << 13) & 0xFFFFFFFF
x ^= x >> 17
x ^= (x << 5) & 0xFFFFFFFF
self.s = x & 0xFFFFFFFF
return self.s
def unit(self):
return self.next() / 0x100000000
def _lattice(rng, w, h):
return [[rng.unit() for _ in range(w)] for _ in range(h)]
def _smooth(t):
return t * t * (3 - 2 * t)
def _sample(grid, x, y):
"""Bilinear value noise on a torus, so the field tiles in both axes."""
h, w = len(grid), len(grid[0])
x0, y0 = int(math.floor(x)) % w, int(math.floor(y)) % h
x1, y1 = (x0 + 1) % w, (y0 + 1) % h
fx, fy = _smooth(x - math.floor(x)), _smooth(y - math.floor(y))
a = grid[y0][x0] + (grid[y0][x1] - grid[y0][x0]) * fx
b = grid[y1][x0] + (grid[y1][x1] - grid[y1][x0]) * fx
return a + (b - a) * fy
def _fbm(grids, x, y, scale):
"""Sum octaves of tileable noise."""
total, amp, norm = 0.0, 1.0, 0.0
for i, g in enumerate(grids):
f = scale * (2 ** i)
total += _sample(g, x * f, y * f) * amp
norm += amp
amp *= 0.5
return total / norm
def _ramp(stops, t):
t = max(0.0, min(1.0, t))
for i in range(len(stops) - 1):
a, b = stops[i], stops[i + 1]
if t <= b[0]:
k = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0])
return tuple(int(a[1 + j] + (b[1 + j] - a[1 + j]) * k) for j in range(4))
return tuple(stops[-1][1:])
FIRE_RAMP = [ # intensity -> RGBA
(0.00, 0, 0, 0, 0),
(0.30, 120, 24, 8, 90),
(0.52, 226, 78, 16, 205),
(0.74, 252, 176, 44, 245),
(1.00, 255, 246, 214, 255),
]
GAS_RAMP = [
(0.00, 0, 0, 0, 0),
(0.34, 52, 26, 78, 70),
(0.60, 96, 52, 140, 140),
(0.82, 148, 96, 196, 190),
(1.00, 208, 176, 236, 215),
]
def fire_frames(seed, w=32, h=64, frames=8, wisp=1.0):
"""Upward-advected noise plume. Scrolling by an exact multiple of the noise
lattice over the frame count makes the loop seamless."""
rng = Rng(seed)
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
out = []
for f in range(frames):
t = f / frames
buf = bytearray(w * h * 4)
for y in range(h):
v = y / (h - 1) # 0 at the base, 1 at the tip
# plume envelope: wide and hot at the base, pinched at the tip
taper = max(0.0, 1.0 - v) ** 0.42
for x in range(w):
u = (x / (w - 1)) * 2 - 1 # -1 .. 1 across the flame
radial = (1.0 - min(1.0, abs(u) / max(0.10, taper * 0.95))) ** 0.7
if radial <= 0:
continue
n = _fbm(grids, x / w, (y / h) - t, 3.0)
lick = 0.55 + 0.75 * (n - 0.5) * wisp
inten = radial * (0.55 + 0.8 * taper) * lick
inten -= 0.16 * v # cool towards the tip
if inten <= 0.02:
continue
r, g, b, a = _ramp(FIRE_RAMP, inten)
i = ((h - 1 - y) * w + x) * 4 # +Y in texture space is up
buf[i:i+4] = bytes((r, g, b, a))
out.append(bytes(buf))
return w, h, out
def gas_frames(seed, w=48, h=48, frames=10):
"""Slow swirling haze that fades out towards the rim."""
rng = Rng(seed)
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
out = []
for f in range(frames):
t = f / frames
buf = bytearray(w * h * 4)
ang = t * 2 * math.pi
for y in range(h):
for x in range(w):
dx = (x / (w - 1)) * 2 - 1
dy = (y / (h - 1)) * 2 - 1
d = math.hypot(dx, dy)
if d >= 1.0:
continue
falloff = (1.0 - d) ** 0.85
# rotate the sample point so the haze churns without popping
sx = dx * math.cos(ang) - dy * math.sin(ang)
sy = dx * math.sin(ang) + dy * math.cos(ang)
n = _fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
inten = falloff * (0.78 + 1.30 * (n - 0.44))
if inten <= 0.03:
continue
r, g, b, a = _ramp(GAS_RAMP, inten)
i = (y * w + x) * 4
buf[i:i+4] = bytes((r, g, b, a))
out.append(bytes(buf))
return w, h, out
def crossed_quads(bone, length, width, axis='y', centred=False):
"""Two quads at right angles so the effect reads from any angle -- the
portable stand-in for a billboard, since glTF cannot billboard.
`axis` picks which bone-local direction the quad grows along. Bone-local +X
runs down the limb, so a flame laid out along X comes out lying sideways;
'y' is that same quad rotated 90 degrees left about Z, which stands it up.
`centred` straddles the origin instead of growing from it."""
pos, uv, nrm, skin, idx = [], [], [], [], []
for q in range(2):
base = len(pos) // 3
for (s, t) in ((0, 0), (1, 0), (1, 1), (0, 1)):
a = (s - 0.5) * width
b = (t - 0.5) * length if centred else t * length
if axis == 'x':
p = (b, a, 0.0) if q == 0 else (b, 0.0, a)
else: # (x, y) -> (-y, x)
p = (-a, b, 0.0) if q == 0 else (0.0, b, a)
pos += list(p)
uv += [s, 1.0 - t]
nrm += [0.0, 0.0, 1.0] if q == 0 else [1.0, 0.0, 0.0]
skin.append(bone)
idx += [base, base + 1, base + 2, base, base + 2, base + 3]
return dict(pos=pos, uv=uv, nrm=nrm, skin=skin, idx=idx)
# desired size as a fraction of the model's world-space extent
SIZES = {
'fire_tail': (0.40, 0.22), # length, width
'fire_small': (0.075, 0.042),
'gas': (1.05, 1.05),
}
def build_for(species, fx, extent, bone_scale):
"""Returns [{kind, bone, geo, w, h, frames}] for one model, or [].
`extent` is the model's world-space size and `bone_scale[i]` how much bone i
already scales its local space; dividing by it keeps every effect the size we
asked for regardless of where in the skeleton it hangs."""
out = []
for node in fx:
cb, bone = node['callback'], node['bone']
if bone < 0 or bone >= len(bone_scale):
continue
k = bone_scale[bone] or 1.0
if cb == FIRE_TAIL:
fl, fw = SIZES['fire_tail']
w, h, fr = fire_frames(species * 7919 + 1, 32, 64, 8)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
elif cb == FIRE_SMALL:
fl, fw = SIZES['fire_small']
w, h, fr = fire_frames(species * 6271 + bone, 24, 40, 8, wisp=1.25)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
elif cb == AURA and species == 92: # Gastly only
fl, fw = SIZES['gas']
w, h, fr = gas_frames(species * 5237 + 3, 48, 48, 10)
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y', centred=True)
out.append(dict(kind='gas', bone=bone, geo=geo, w=w, h=h, frames=fr))
return out
+747
View File
@@ -0,0 +1,747 @@
#!/usr/bin/env python3
"""
Model extraction: FRAGMENT module -> geometry, textures, skeleton, animations.
Self-contained copy of tools/model_viewer/extract_model.py, taking raw bytes so
it can be fed straight from the ROM. See ../README.md for the format notes.
"""
import json, os, struct, sys, zlib
BASE = 0x8FF00000
# ---------------------------------------------------------------- geo layout
CMD_SIZES = {
0x00:0x08, 0x01:0x04, 0x02:0x08, 0x03:0x08, 0x04:0x04, 0x05:0x04, 0x06:0x04,
0x07:0x08, 0x08:0x0C, 0x09:0x04, 0x0A:0x08, 0x0B:0x18, 0x0C:0x04, 0x0D:0x04,
0x0E:0x04, 0x0F:0x04, 0x10:0x04, 0x11:0x04, 0x12:0x04, 0x13:0x08, 0x14:0x0C,
0x15:0x0C, 0x16:0x04, 0x17:0x14, 0x18:0x08, 0x19:0x08, 0x1A:0x04, 0x1B:0x10,
0x1C:0x10, 0x1D:0x1C, 0x1E:0x08, 0x1F:0x18, 0x20:0x14, 0x21:0x10, 0x22:0x08,
0x23:0x10, 0x24:0x04, 0x25:0x04, 0x26:0x14,
}
class Fragment:
def __init__(self, data, name='<bytes>'):
self.d = data if isinstance(data, (bytes, bytearray)) else open(data, 'rb').read()
self.name = name if isinstance(data, (bytes, bytearray)) else str(data)
if self.d[8:0x10] != b'FRAGMENT':
raise ValueError(f'{self.name}: not a FRAGMENT module')
self.hdrSize, self.relocOff, self.sizeRom, self.sizeRam = struct.unpack_from('>4I', self.d, 0x10)
def off(self, ptr):
return None if ptr == 0 else ptr - BASE
def u8(self, o): return self.d[o]
def s8(self, o): return struct.unpack_from('>b', self.d, o)[0]
def u16(self, o): return struct.unpack_from('>H', self.d, o)[0]
def s16(self, o): return struct.unpack_from('>h', self.d, o)[0]
def u32(self, o): return struct.unpack_from('>I', self.d, o)[0]
def s32(self, o): return struct.unpack_from('>i', self.d, o)[0]
def ptr(self, o): return self.off(self.u32(o))
def root(self):
"""The entry stub ends with `lui rX, hi; addiu rX, rX, lo` loading the root struct."""
for o in range(0x20, 0x80, 4):
w = self.u32(o)
if (w >> 26) != 0x0F: # lui
continue
reg = (w >> 16) & 0x1F
w2 = self.u32(o + 4)
if (w2 >> 26) == 0x09 and ((w2 >> 21) & 0x1F) == reg: # addiu rX, rX, imm
return ((self.u16(o + 2) << 16) + self.s16(o + 6)) - BASE
raise RuntimeError('could not locate root struct')
def ptr_list(self, o):
out = []
while True:
p = self.ptr(o)
if p is None:
return out
out.append(p)
o += 4
# --------------------------------------------------------------- F3DEX2 exec
def signed(v, bits):
m = 1 << (bits - 1)
return (v ^ m) - m
class Model:
"""Walks the geo layout, executes the display lists, accumulates draw data."""
def __init__(self, frag):
self.f = frag
r = frag.root()
self.species = frag.u16(r)
self.geoLayouts = frag.ptr_list(frag.ptr(r + 0x08))
self.anims = frag.ptr_list(frag.ptr(r + 0x0C))
self.auxAnims = frag.ptr_list(frag.ptr(r + 0x10))
self.textures = [] # {fmt, siz, w, h, texels, data}
self.tluts = [] # palettes: {count, data, dl}
self.bones = [] # {parent, boneId, chan, t, r, s}
self.boneById = {}
self.prims = [] # {tex, cull, verts:[...], tris:[...]}
self.primsByKey = {}
self.vtxBase = None
self.rootScale = [1.0, 1.0, 1.0]
self.fx = [] # geo cmd 0x08 procedural effect nodes
self.warnings = []
# ---- textures -------------------------------------------------------
def read_texture_table(self, off, count):
f = self.f
for i in range(count):
o = off + i * 0xC
self.textures.append(dict(
fmt=f.u8(o), siz=f.u8(o + 1), w=f.s16(o + 2),
h=f.u16(o + 4), texels=f.u16(o + 6), data=f.ptr(o + 8)))
def read_tlut_table(self, off, count):
"""Palettes reuse the texture-record layout: the count sits in the width
field, the palette data in the next word, and unk_08 is the DL that loads
it (src/12D80.c func_80015B20). The DL is authoritative, so run it."""
f = self.f
for i in range(count):
o = off + i * 0xC
rec = dict(count=f.u16(o + 2), data=f.ptr(o + 4), dl=f.ptr(o + 8))
dl = rec['dl']
if dl is not None:
for _ in range(16):
w0, w1 = struct.unpack_from('>II', f.d, dl)
op = w0 >> 24
if op == 0xFD: # G_SETTIMG
rec['data'] = f.off(w1)
elif op == 0xF0: # G_LOADTLUT
rec['count'] = ((w1 >> 14) & 0x3FF) + 1
elif op == 0xDF:
break
dl += 8
self.tluts.append(rec)
# ---- geo layout -----------------------------------------------------
def build(self):
self.curTex = -1
self.curTlut = -1
self.curMat = None
self.curTexAnim = -1
# Mirrors gCurGraphNodeList in src/geo_layout.c: stack[-1] is the slot the
# next node command writes to, and a node's parent -- and the bone whose
# matrix is live -- is the slot *below* it (func_80017AC4).
self.stack = [-1]
# The RSP vertex cache persists across display lists: a bone's list often
# preloads verts that the *next* bone's list then indexes, which is how
# these models get blended joints. Each slot remembers the bone whose
# matrix was current when it was loaded.
self.vbuf = [None] * 64
self.walk(self.geoLayouts[0])
def walk(self, o, depth=0):
f = self.f
if depth > 32:
return
while True:
cmd = f.u8(o)
size = CMD_SIZES.get(cmd)
if size is None:
self.warnings.append(f'unknown geo cmd {cmd:#04x} at {o:#x}')
return
if cmd == 0x01 or cmd == 0x04: # end / return
return
if cmd in (0x00, 0x03): # branch (with return)
self.walk(f.ptr(o + 4), depth + 1)
elif cmd == 0x02: # jump (no return)
o = f.ptr(o + 4)
continue
elif cmd == 0x05: # open node
self.stack.append(self.stack[-1])
elif cmd == 0x06: # close node
self.stack.pop()
elif cmd == 0x17: # model header
self.read_texture_table(f.ptr(o + 8), f.s16(o + 2))
if f.ptr(o + 0xC):
self.read_tlut_table(f.ptr(o + 0xC), f.s16(o + 4))
self.vtxBase = f.ptr(o + 0x10)
self.nVerts = f.s16(o + 6)
elif cmd == 0x08: # procedural effect callback
self.fx.append(dict(bone=self.curBone(), callback=f.u32(o + 4),
arg=f.ptr(o + 8)))
elif cmd == 0x1C: # uniform scale node
self.rootScale = [f.s32(o + 4) / 65536.0, f.s32(o + 8) / 65536.0,
f.s32(o + 0xC) / 65536.0]
elif cmd == 0x1D: # bone / joint node
idx = len(self.bones)
self.bones.append(dict(
parent=self.curBone(), boneId=f.u8(o + 1), flags=f.u8(o + 2),
chan=f.s8(o + 3),
t=[f.s16(o + 4), f.s16(o + 6), f.s16(o + 8)],
r=[f.s16(o + 0xA), f.s16(o + 0xC), f.s16(o + 0xE)],
s=[f.s32(o + 0x10) / 65536.0, f.s32(o + 0x14) / 65536.0,
f.s32(o + 0x18) / 65536.0]))
self.boneById[f.u8(o + 1)] = idx
self.stack[-1] = idx
elif cmd == 0x23: # set texture / material
self.curTex = f.s16(o + 8)
self.curTlut = f.s16(o + 0xA)
self.curMat = f.ptr(o + 4)
# offset 0x02 is the texture-animation channel; -1 means static.
# func_800176DC swaps this material's texture per frame from the
# auxiliary animation's channel stream.
self.curTexAnim = f.s16(o + 2)
elif cmd == 0x22: # display list on current bone
self.run_dl(f.ptr(o + 4), self.curBone())
elif cmd == 0x1E: # display list on named bone
self.run_dl(f.ptr(o + 4), self.boneById.get(f.s16(o + 2), self.curBone()))
elif cmd in (0x20, 0x21): # display list + own transform
self.run_dl(f.ptr(o + (0x10 if cmd == 0x20 else 0xC)), self.curBone())
o += size
def curBone(self):
return self.stack[-2] if len(self.stack) >= 2 else -1
# ---- display lists --------------------------------------------------
def run_dl(self, o, bone, depth=0):
if o is None or depth > 8:
return
f = self.f
vbuf = self.vbuf
cull = 0x400
while True:
w0, w1 = struct.unpack_from('>II', f.d, o)
op = w0 >> 24
o += 8
if op == 0xDF: # G_ENDDL
return
if op == 0xDE: # G_DL
self.run_dl(f.off(w1), bone, depth + 1)
if (w0 >> 16) & 0xFF: # branch, not call
return
continue
if op == 0x01: # G_VTX
n = (w0 >> 12) & 0xFF
v0 = ((w0 & 0xFFF) >> 1) - n
a = f.off(w1)
for i in range(n):
p = a + i * 0x10
if 0 <= v0 + i < len(vbuf):
vbuf[v0 + i] = (
f.s16(p), f.s16(p + 2), f.s16(p + 4), # position
f.s16(p + 8) / 32.0, f.s16(p + 10) / 32.0, # s, t (S10.5)
f.s8(p + 12), f.s8(p + 13), f.s8(p + 14), # normal
f.u8(p + 15), # alpha
bone) # owning bone
continue
if op == 0xD9: # G_GEOMETRYMODE
cull = (cull & (w0 & 0xFFFFFF)) | w1
continue
if op in (0x05, 0x06): # G_TRI1 / G_TRI2
prim = self.prim_for(self.curTex, self.curTlut, self.curMat,
self.curTexAnim, cull & 0x600)
def emit(a, b, c):
tri = []
for idx in (a, b, c):
v = vbuf[idx] if idx < len(vbuf) else None
if v is None:
return
j = prim['_remap'].get(v)
if j is None:
j = len(prim['verts'])
prim['_remap'][v] = j
prim['verts'].append(v)
tri.append(j)
if (cull & 0x200) and not (cull & 0x400):
tri.reverse()
prim['tris'].append(tri)
emit(((w0 >> 16) & 0xFF) // 2, ((w0 >> 8) & 0xFF) // 2, (w0 & 0xFF) // 2)
if op == 0x06:
emit(((w1 >> 16) & 0xFF) // 2, ((w1 >> 8) & 0xFF) // 2, (w1 & 0xFF) // 2)
continue
# everything else (SETTILE / sync / ...) is state we reconstruct
# from the texture table instead, so it is skipped.
def prim_for(self, tex, tlut, mat, texAnim, cull):
key = (tex, tlut, mat, texAnim, cull)
p = self.primsByKey.get(key)
if p is None:
p = dict(tex=tex, tlut=tlut, mat=mat, texAnim=texAnim, cull=cull,
verts=[], tris=[], _remap={})
self.primsByKey[key] = p
self.prims.append(p)
return p
def tile_palette(self, mat):
"""CI4 selects a 16-entry block of the TLUT via the render tile's palette
field; read it from the material display list's final G_SETTILE."""
if mat is None:
return 0
pal = 0
for _ in range(16):
w0, w1 = struct.unpack_from('>II', self.f.d, mat)
op = w0 >> 24
if op == 0xF5 and ((w1 >> 24) & 7) == 0: # G_SETTILE, render tile
pal = (w1 >> 20) & 0xF
elif op == 0xDF:
break
mat += 8
return pal
# ---------------------------------------------------------------- animations
def bitfield(f, base, index, bits):
"""src/F420.c func_80010500: signed `bits`-wide field at bit index*bits.
C integer division truncates toward zero; Python's floors. That only differs
for negative indices, which is exactly what an empty channel produces, so the
truncating form is used here to match the hardware."""
bitpos = index * bits
word = bitpos // 16 if bitpos >= 0 else -((-bitpos) // 16) # C: trunc to zero
rem = bitpos - word * 16 # C: sign follows bitpos
o = base + word * 2
v = (f.u16(o) << 16) | f.u16(o + 2)
v = (v << (rem & 31)) & 0xFFFFFFFF # MIPS masks the shift to 5 bits
return signed(v >> (32 - bits), bits)
class Animation:
"""src/17300.c. Two sampling modes: packed per-frame streams (default) and
hermite keyframes (flags & 8)."""
def __init__(self, frag, off):
f = self.f = frag
self.off = off
# The flags live in the LOW byte of the u16 at +0 -- reading the byte
# AT +0 gets the always-zero high byte, which silently turns every
# hermite animation (flags & 8: Pidgeot, Dodrio, Exeggutor, Tangela,
# Magmar) into a packed-stream read of keyframe tables.
self.flags = f.u16(off)
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
self.nFrames = f.u16(off + 0xA)
self.chanTable = f.ptr(off + 0xC)
self.scaleData = f.ptr(off + 0x10)
self.rotData = f.ptr(off + 0x14)
self.transData = f.ptr(off + 0x18)
def chan(self, i):
o = self.chanTable + i * 0xA
f = self.f
return dict(nScale=f.u8(o), nRot=f.u8(o + 1), nTrans=f.u8(o + 2),
interp=f.u8(o + 3), oScale=f.u16(o + 4),
oRot=f.u16(o + 6), oTrans=f.u16(o + 8))
# -- packed stream sampling (flags & 8 == 0) --------------------------
# A count of 0 means the component has no stream. In the ROM that only
# ever happens in HERMITE animations, where a count under 2 means "the
# offset field IS the constant value" -- no packed animation of any of the
# 151 species carries an empty channel, so the bind-pose fallback here is
# dead code kept as a safety net.
def _trans_packed(self, c, frame):
if c['nTrans'] == 0:
return None
bits = 16 if (self.flags & 4) else 12
if c['nTrans'] == 1:
# (s16) casts both ways: func_80016848 reads the u16 offset field
# back as a signed constant.
return float(signed(c['oTrans'], 16) if (self.flags & 4)
else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
i = c['oTrans'] + min(frame, c['nTrans'] - 1)
return float(bitfield(self.f, self.transData, i, bits))
def _rot_packed(self, c, frame):
if c['nRot'] == 0:
return None
if c['nRot'] == 1:
return signed((c['oRot'] * 16) & 0xFFFF, 16)
i = c['oRot'] + min(frame, c['nRot'] - 1)
return signed((bitfield(self.f, self.rotData, i, 12) * 16) & 0xFFFF, 16)
def _scale_packed(self, c, frame):
if c['nScale'] == 0:
return None
if c['nScale'] == 1:
return c['oScale'] / 1000.0
i = c['oScale'] + min(frame, c['nScale'] - 1)
return self.f.s16(self.scaleData + i * 2) / 1000.0
# -- hermite keyframe sampling (flags & 8) ----------------------------
def _hermite(self, base, n, frame, wide):
f = self.f
stride = 8 if wide else 6
def key(i):
o = base + i * stride
return (f.s16(o), f.s16(o + 2), f.s16(o + 4),
f.s16(o + 6) if wide else f.s16(o + 4))
k0 = key(0)
if k0[0] >= frame:
return float(k0[1])
last = key(n - 1)
if frame >= last[0]:
return float(last[1])
i = 0
while i < n - 2:
if frame < key(i + 1)[0]:
break
i += 1
a, b = key(i), key(i + 1)
x = (frame - a[0]) / 30.0
y = 30.0 / (b[0] - a[0])
x2, x3 = x * x, x * x * x
y2, y3 = y * y, y * y * y
return (a[1] * (2 * x3 * y3 - 3 * x2 * y2 + 1)
+ b[1] * (-2 * x3 * y3 + 3 * x2 * y2)
+ (a[3] if wide else a[2]) * (x3 * y2 - 2 * x2 * y + x)
+ b[2] * (x3 * y2 - x2 * y))
def _trans_key(self, c, frame):
if c['nTrans'] < 2:
return float(signed(c['oTrans'], 16))
return self._hermite(self.transData + c['oTrans'] * 2, c['nTrans'], frame, c['interp'] & 1)
def _rot_key(self, c, frame):
if c['nRot'] < 2:
deg = signed(c['oRot'], 16) / 10.0
else:
deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0
deg %= 360.0
# func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above
# 180 degrees to its negative twin. Same binary angle either way, but
# the packer stores i16 with clamping, so an unwrapped 350-degree
# value would pin at 32767 (= 180 degrees) instead.
return signed(int(deg / 360.0 * 65536.0) & 0xFFFF, 16)
def _scale_key(self, c, frame):
if c['nScale'] < 2:
return signed(c['oScale'], 16) / 100.0
return self._hermite(self.scaleData + c['oScale'] * 2, c['nScale'], frame, c['interp'] & 4) / 100.0
def sample_trs(self, chanIndex, frame, bind=None):
"""Returns (translation, rotation, scale) triples for one bone. Components
whose channel carries no data fall back to the bone's bind value."""
base = chanIndex * 3
if base < 0 or base + 2 >= self.nChannels:
return None
cs = [self.chan(base + i) for i in range(3)]
if self.flags & 8:
out = ([self._trans_key(c, frame) for c in cs],
[self._rot_key(c, frame) for c in cs],
[self._scale_key(c, frame) for c in cs])
else:
out = ([self._trans_packed(c, frame) for c in cs],
[self._rot_packed(c, frame) for c in cs],
[self._scale_packed(c, frame) for c in cs])
if bind is None:
bind = ([0, 0, 0], [0, 0, 0], [1.0, 1.0, 1.0])
return tuple([v if v is not None else bind[k][i] for i, v in enumerate(comp)]
for k, comp in enumerate(out))
class AuxAnimation:
"""Texture animation (src/18140.c). Same header shape as the skeletal
animations, but each channel is a per-frame stream of texture-table indices
that func_800176DC substitutes into a material."""
def __init__(self, frag, off):
f = self.f = frag
self.flags = f.u16(off) # low byte, same layout as Animation
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
self.nFrames = f.u16(off + 0xA)
self.chanTable = f.ptr(off + 0xC)
self.data = f.ptr(off + 0x10)
def sample(self, chan, frame):
"""func_80017540: index into the stream, clamped to the channel length."""
if not (0 <= chan < self.nChannels) or self.chanTable is None:
return None
o = self.chanTable + chan * 4
count, base = self.f.u16(o), self.f.u16(o + 2)
if count == 0:
return None
i = base + (frame if frame < count else count - 1)
return self.f.u8(self.data + i)
def track(self, chan):
n = max(1, self.nFrames)
return [self.sample(chan, i) for i in range(n)]
# ------------------------------------------------------------------ textures
def rgba5551(p):
return (((p >> 11) & 0x1F) * 255 // 31, ((p >> 6) & 0x1F) * 255 // 31,
((p >> 1) & 0x1F) * 255 // 31, 255 if (p & 1) else 0)
def decode_texture(f, tex, tlut=None, palette=0):
"""Returns (w, h, RGBA8 bytes) for the N64 texture formats these models use."""
w, h, fmt, siz, addr = tex['w'], tex['h'], tex['fmt'], tex['siz'], tex['data']
out = bytearray(w * h * 4)
d = f.d
n = w * h
def nibble(i):
return (d[addr + i // 2] >> (0 if i & 1 else 4)) & 0xF
if fmt == 0 and siz == 2: # RGBA16 (5/5/5/1)
for i in range(n):
out[i*4:i*4+4] = bytes(rgba5551(struct.unpack_from('>H', d, addr + i * 2)[0]))
elif fmt == 0 and siz == 3: # RGBA32
out[:] = d[addr:addr + n * 4]
elif fmt == 2: # CI4 / CI8 -> RGBA16 palette
pal = []
if tlut is not None and tlut['data'] is not None:
base = tlut['data'] + (palette * 16 * 2 if siz == 0 else 0)
for i in range(16 if siz == 0 else 256):
pal.append(bytes(rgba5551(struct.unpack_from('>H', d, base + i * 2)[0])))
if not pal:
pal = [b'\xff\x00\xff\xff'] * 256
for i in range(n):
idx = nibble(i) if siz == 0 else d[addr + i]
out[i*4:i*4+4] = pal[idx % len(pal)]
elif fmt == 3: # IA16 / IA8 / IA4
for i in range(n):
if siz == 2:
v = struct.unpack_from('>H', d, addr + i * 2)[0]
l, a = v >> 8, v & 0xFF
elif siz == 1:
v = d[addr + i]
l, a = (v >> 4) * 17, (v & 0xF) * 17
else:
v = nibble(i)
l, a = ((v >> 1) * 255) // 7, 255 if (v & 1) else 0
out[i*4:i*4+4] = bytes((l, l, l, a))
elif fmt == 4: # I8 / I4
for i in range(n):
l = d[addr + i] if siz == 1 else nibble(i) * 17
out[i*4:i*4+4] = bytes((l, l, l, 255))
else:
for i in range(n): # unsupported: magenta
out[i*4:i*4+4] = b'\xff\x00\xff\xff'
return w, h, bytes(out)
def png(w, h, rgba):
"""Minimal PNG encoder (no PIL dependency)."""
raw = b''.join(b'\x00' + rgba[y*w*4:(y+1)*w*4] for y in range(h))
def chunk(tag, data):
c = tag + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)
return (b'\x89PNG\r\n\x1a\n'
+ chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(raw, 9))
+ chunk(b'IEND', b''))
# ---------------------------------------------------------------------- main
def unique(seq):
"""The distinct values of `seq`, in the order they first appear.
Used where a set used to be. A set of small ints iterates in hash-slot
order, which is stable across runs but is neither insertion nor sort order
and is a CPython implementation detail -- and here it decided the order
textures get REGISTERED in, and so their indices in the packed file. Order
of appearance is a property of the data instead of the interpreter, which
is what lets the Lua extractor produce the same file.
"""
seen, out = set(), []
for v in seq:
if v in seen:
continue
seen.add(v)
out.append(v)
return out
def dedupe_fx(nodes):
"""The geo layout's effect callbacks, once each, IN THE ORDER THEY APPEAR.
A geo layout can name the same callback on the same bone more than once
(the walk visits a subtree twice), so these have to be deduplicated, and it
used to be done by dropping them through a set. That was a real bug rather
than a style point: the set held tuples containing strings, so its iteration
order moved with PYTHONHASHSEED, and the generated flames of every species
carrying more than one -- Ponyta, Rapidash and Moltres -- came out in a
different order, with different seeds and therefore different pixels, on
different runs of the same build.
Order of appearance is the game's own order, it is stable, and it is what
the Lua extractor can reproduce.
"""
seen, out = set(), []
for node in nodes:
key = (node['bone'], node['callback'], node['arg'])
if key in seen:
continue
seen.add(key)
out.append(dict(node))
return out
def extract(path, name=None, raw=False):
"""`raw=True` carries each texture's decoded RGBA8 bytes as `rgba` instead
of encoding a PNG data URI into `png`.
The viewer and the glTF export both want a PNG, so that stays the default.
The mod's own packer wants the pixels: it stores them uncompressed, so that
its Lua counterpart -- which has no zlib whose output is guaranteed to
agree with this one's byte for byte -- can be checked against it exactly.
"""
f = Fragment(path, name or str(path))
m = Model(f)
m.build()
import base64
auxAnims = [AuxAnimation(f, o) for o in m.auxAnims]
texIndexMap, texOut = {}, []
def register(texIdx, tlut, pal):
key = (texIdx, tlut, pal)
if key in texIndexMap:
return texIndexMap[key]
if not (0 <= texIdx < len(m.textures)):
return -1
texIndexMap[key] = len(texOut)
tl = m.tluts[tlut] if 0 <= tlut < len(m.tluts) else None
w, h, rgba = decode_texture(f, m.textures[texIdx], tl, pal)
rec = dict(index=texIdx, w=w, h=h)
if raw:
rec['rgba'] = rgba
else:
rec['png'] = ('data:image/png;base64,'
+ base64.b64encode(png(w, h, rgba)).decode())
texOut.append(rec)
return texIndexMap[key]
for p in m.prims:
if not p['tris']:
continue
pal = m.tile_palette(p['mat'])
register(p['tex'], p['tlut'], pal)
# An animated material can swap to any texture its channel names, so all
# of them have to be decoded up front.
if p['texAnim'] >= 0:
for a in auxAnims:
for t in unique(a.track(p['texAnim'])):
if t is not None:
register(t, p['tlut'], pal)
prims = []
for p in m.prims:
if not p['tris']:
continue
pos, uv, nrm, skin = [], [], [], []
pal = m.tile_palette(p['mat'])
ti = texIndexMap.get((p['tex'], p['tlut'], pal), -1)
# texture-table index -> slot in texOut, for the animated swap
texMap = {}
if p['texAnim'] >= 0:
for a in auxAnims:
for t in unique(a.track(p['texAnim'])):
if t is not None and (t, p['tlut'], pal) in texIndexMap:
texMap[t] = texIndexMap[(t, p['tlut'], pal)]
tw, th = (m.textures[p['tex']]['w'], m.textures[p['tex']]['h']) if ti >= 0 else (32, 32)
for v in p['verts']:
pos += [v[0], v[1], v[2]]
uv += [v[3] / tw, v[4] / th]
nrm += [v[5] / 127.0, v[6] / 127.0, v[7] / 127.0]
skin.append(v[9])
prims.append(dict(tex=ti, cull=p['cull'], texAnim=p['texAnim'],
texMap={str(k): v for k, v in sorted(texMap.items())},
pos=pos, uv=uv, nrm=nrm, skin=skin,
idx=[i for t in p['tris'] for i in t]))
def compress(values, nd):
"""Constant tracks collapse to a scalar; most channels never move."""
r = [round(v, nd) for v in values]
return r[0] if all(v == r[0] for v in r) else r
anims = []
for i, off in enumerate(m.anims):
a = Animation(f, off)
nf = max(1, a.nFrames)
tracks = []
for b in m.bones:
ch = b['chan']
bind = (b['t'], b['r'], b['s'])
if ch < 0 or a.sample_trs(ch, 0, bind) is None:
tracks.append(None)
continue
samples = [a.sample_trs(ch, fr, bind) for fr in range(nf)]
tracks.append(dict(
t=[compress([s[0][k] for s in samples], 3) for k in range(3)],
r=[compress([s[1][k] for s in samples], 0) for k in range(3)],
s=[compress([s[2][k] for s in samples], 5) for k in range(3)]))
anims.append(dict(index=i, frames=nf, flags=a.flags,
channels=a.nChannels, loopStart=a.loopStart, tracks=tracks))
auxOut = []
for i, a in enumerate(auxAnims):
auxOut.append(dict(index=i, frames=max(1, a.nFrames), flags=a.flags,
loopStart=a.loopStart,
channels=[a.track(c) for c in range(a.nChannels)]))
return dict(
species=m.species,
name=SPECIES.get(m.species, f'#{m.species}'),
file=os.path.basename(f.name),
rootScale=m.rootScale,
bones=[dict(parent=b['parent'], boneId=b['boneId'], chan=b['chan'],
flags=b['flags'], t=b['t'], r=b['r'], s=b['s']) for b in m.bones],
textures=texOut,
prims=prims,
anims=anims,
auxAnims=auxOut,
fx=dedupe_fx(m.fx),
warnings=m.warnings,
)
SPECIES = {}
_NAMES = (
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
for _i, _n in enumerate(_NAMES):
SPECIES[_i + 1] = _n
if __name__ == '__main__':
here = os.path.dirname(os.path.abspath(__file__))
src = sys.argv[1] if len(sys.argv) > 1 else 'assets/us/pokemon_models/24.bin'
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, 'model.js')
data = extract(src)
body = json.dumps(data, separators=(',', ':'))
with open(dst, 'w') as fp:
fp.write('window.PKMN_MODEL = ' + body + ';\n')
tris = sum(len(p['idx']) // 3 for p in data['prims'])
print(f"{data['name']} (#{data['species']}) bones={len(data['bones'])} prims={len(data['prims'])} "
f"tris={tris} textures={len(data['textures'])} anims={len(data['anims'])}")
print(f"frames per anim: {[a['frames'] for a in data['anims']]}")
if data['warnings']:
print('warnings:', data['warnings'][:5])
print(f'wrote {dst} ({os.path.getsize(dst)/1024:.0f} KB)')
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
glTF 2.0 binary writer.
Each game bone becomes two nodes -- a pivot carrying translation/rotation and a
leaf carrying the accumulated scale -- because the game keeps scale out of the
matrix chain while glTF propagates it to children. See ../README.md.
"""
import json
import math
import struct
ND = 7 # decimals kept on node rest transforms
def quat_from_euler(r):
"""The game's rotation is Rx*Ry*Rz in row-vector form (src/F420.c
func_8000F730); build that basis as glTF columns and convert."""
sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi)
sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi)
sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.pi)
# rows of the game matrix become the columns of the glTF rotation
m = ((cy*cz, sx*sy*cz - cx*sz, cx*sy*cz + sx*sz),
(cy*sz, sx*sy*sz + cx*cz, cx*sy*sz - sx*cz),
(-sy, sx*cy, cx*cy))
tr = m[0][0] + m[1][1] + m[2][2]
if tr > 0:
s = math.sqrt(tr + 1.0) * 2
w = 0.25 * s
x = (m[2][1] - m[1][2]) / s
y = (m[0][2] - m[2][0]) / s
z = (m[1][0] - m[0][1]) / s
elif m[0][0] > m[1][1] and m[0][0] > m[2][2]:
s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2
w = (m[2][1] - m[1][2]) / s
x = 0.25 * s
y = (m[0][1] + m[1][0]) / s
z = (m[0][2] + m[2][0]) / s
elif m[1][1] > m[2][2]:
s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2
w = (m[0][2] - m[2][0]) / s
x = (m[0][1] + m[1][0]) / s
y = 0.25 * s
z = (m[1][2] + m[2][1]) / s
else:
s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2
w = (m[1][0] - m[0][1]) / s
x = (m[0][2] + m[2][0]) / s
y = (m[1][2] + m[2][1]) / s
z = 0.25 * s
n = math.sqrt(x*x + y*y + z*z + w*w) or 1.0
return [x/n, y/n, z/n, w/n]
def pose(bones, sample_fn):
"""Returns (pivotT, pivotQ, jointS) for every bone at one instant."""
acc, pt, pq, js = [], [], [], []
for i, b in enumerate(bones):
t, r, s = sample_fn(i, b)
pa = acc[b['parent']] if b['parent'] >= 0 else (1.0, 1.0, 1.0)
pt.append([t[0]*pa[0], t[1]*pa[1], t[2]*pa[2]])
pq.append(quat_from_euler(r))
a = (pa[0]*s[0], pa[1]*s[1], pa[2]*s[2])
acc.append(a)
js.append(list(a))
return pt, pq, js
# ------------------------------------------------------------------ glTF build
class Glb:
def __init__(self):
self.buf = bytearray()
self.views = []
self.accessors = []
def view(self, data, target=None):
while len(self.buf) % 4:
self.buf.append(0)
off = len(self.buf)
self.buf += data
v = dict(buffer=0, byteOffset=off, byteLength=len(data))
if target:
v['target'] = target
self.views.append(v)
return len(self.views) - 1
def accessor(self, data, ctype, atype, count, target=None,
minmax=None, normalized=False):
a = dict(bufferView=self.view(data, target), componentType=ctype,
count=count, type=atype)
if normalized:
a['normalized'] = True
if minmax:
a['min'], a['max'] = minmax
self.accessors.append(a)
return len(self.accessors) - 1
def floats(self, values, atype, target=None, minmax=None):
n = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}[atype]
return self.accessor(struct.pack(f'<{len(values)}f', *values),
5126, atype, len(values) // n, target, minmax)
def finish(self, gltf):
gltf['buffers'] = [dict(byteLength=len(self.buf))]
gltf['bufferViews'] = self.views
gltf['accessors'] = self.accessors
js = json.dumps(gltf, separators=(',', ':')).encode()
js += b' ' * (-len(js) % 4)
bin_ = bytes(self.buf) + b'\0' * (-len(self.buf) % 4)
return (struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(bin_))
+ struct.pack('<II', len(js), 0x4E4F534A) + js
+ struct.pack('<II', len(bin_), 0x004E4942) + bin_)
def build_glb(data, pngs):
bones = data['bones']
nb = len(bones)
g = Glb()
gltf = dict(asset=dict(version='2.0',
generator='pokestadium tools/model_viewer/export_gltf.py'))
# ---- nodes: root scale, then a pivot/joint pair per bone ----------------
bind_t, bind_q, bind_s = pose(bones, lambda i, b: (b['t'], b['r'], b['s']))
nodes = [dict(name='model_root', scale=[round(v, 6) for v in data['rootScale']])]
pivot_id = [0] * nb
joint_id = [0] * nb
for i, b in enumerate(bones):
pivot_id[i] = len(nodes)
nodes.append(dict(name=f'bone{b["boneId"]:02d}',
translation=[round(v, ND) for v in bind_t[i]],
rotation=[round(v, ND) for v in bind_q[i]]))
joint_id[i] = len(nodes)
nodes.append(dict(name=f'bone{b["boneId"]:02d}_scale',
scale=[round(v, ND) for v in bind_s[i]]))
nodes[pivot_id[i]]['children'] = [joint_id[i]]
for i, b in enumerate(bones):
parent = pivot_id[b['parent']] if b['parent'] >= 0 else 0
nodes[parent].setdefault('children', []).append(pivot_id[i])
# ---- textures / materials ---------------------------------------------
images, samplers, textures, materials = [], [], [], []
if pngs:
samplers.append(dict(magFilter=9729, minFilter=9729,
wrapS=33071, wrapT=33071)) # LINEAR, CLAMP
for i, blob in enumerate(pngs):
images.append(dict(mimeType='image/png',
bufferView=g.view(blob), name=f'tex{i:02d}'))
textures.append(dict(sampler=0, source=i))
prims_out = []
for p in data['prims']:
nv = len(p['pos']) // 3
pos = [float(v) for v in p['pos']]
mn = [min(pos[k::3]) for k in range(3)]
mx = [max(pos[k::3]) for k in range(3)]
attrs = dict(
POSITION=g.floats(pos, 'VEC3', 34962, (mn, mx)),
NORMAL=g.floats([float(v) for v in p['nrm']], 'VEC3', 34962),
TEXCOORD_0=g.floats([float(v) for v in p['uv']], 'VEC2', 34962),
JOINTS_0=g.accessor(
struct.pack(f'<{nv*4}H', *[v for j in p['skin'] for v in (j, 0, 0, 0)]),
5123, 'VEC4', nv, 34962),
WEIGHTS_0=g.floats([v for _ in range(nv) for v in (1.0, 0.0, 0.0, 0.0)],
'VEC4', 34962),
)
idx = g.accessor(struct.pack(f'<{len(p["idx"])}H', *p['idx']),
5123, 'SCALAR', len(p['idx']), 34963)
blend = p.get('blend')
mat = dict(
name=f'mat{len(materials):02d}',
alphaMode='BLEND' if blend else 'MASK',
doubleSided=bool(blend) or not (p['cull'] & 0x400),
pbrMetallicRoughness=dict(metallicFactor=0.0, roughnessFactor=0.9),
)
if blend:
# generated effects are unlit so they read as emissive fire/gas
mat['emissiveFactor'] = [1.0, 1.0, 1.0]
else:
mat['alphaCutoff'] = 0.5
if p['tex'] >= 0:
mat['pbrMetallicRoughness']['baseColorTexture'] = dict(index=p['tex'])
if blend:
mat['emissiveTexture'] = dict(index=p['tex'])
materials.append(mat)
prims_out.append(dict(attributes=attrs, indices=idx,
material=len(materials) - 1))
skin_node = len(nodes)
nodes.append(dict(name=data['name'], mesh=0, skin=0))
ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
gltf['skins'] = [dict(joints=joint_id, skeleton=0,
inverseBindMatrices=g.floats(ident * nb, 'MAT4'))]
gltf['meshes'] = [dict(name=data['name'], primitives=prims_out)]
# ---- animations --------------------------------------------------------
anims = []
for a in data['anims']:
nf = a['frames']
times = [round(fr / 30.0, 6) for fr in range(nf)] # authored at 30 fps
def sample_fn(i, b, _a=a):
tr = _a['tracks'][i]
if not tr:
return b['t'], b['r'], b['s']
pick = lambda c, fr: (c if isinstance(c, (int, float))
else c[min(fr, len(c) - 1)])
return ([pick(c, sample_fn.fr) for c in tr['t']],
[pick(c, sample_fn.fr) for c in tr['r']],
[pick(c, sample_fn.fr) for c in tr['s']])
seq_t = [[] for _ in range(nb)]
seq_q = [[] for _ in range(nb)]
seq_s = [[] for _ in range(nb)]
for fr in range(nf):
sample_fn.fr = fr
pt, pq, js = pose(bones, sample_fn)
for i in range(nb):
if seq_q[i] and sum(x*y for x, y in zip(seq_q[i][-1], pq[i])) < 0:
pq[i] = [-v for v in pq[i]] # keep quaternions continuous
seq_t[i].append(pt[i]); seq_q[i].append(pq[i]); seq_s[i].append(js[i])
channels, samplers_a = [], []
cache = {}
def time_accessor(keys):
if keys not in cache:
t = times if keys == nf else [times[0], times[-1]]
cache[keys] = g.floats(t, 'SCALAR', minmax=([t[0]], [t[-1]]))
return cache[keys]
for i in range(nb):
for seq, path, node, dflt in (
(seq_t[i], 'translation', pivot_id[i], nodes[pivot_id[i]]['translation']),
(seq_q[i], 'rotation', pivot_id[i], nodes[pivot_id[i]]['rotation']),
(seq_s[i], 'scale', joint_id[i], nodes[joint_id[i]]['scale'])):
const = all(v == seq[0] for v in seq)
# A constant channel can only be dropped when it already equals the
# node's rest value; otherwise the node would sit in its bind pose.
if const and [round(c, ND) for c in seq[0]] == dflt:
continue
if const:
seq = [seq[0], seq[0]]
time_acc = time_accessor(len(seq))
flat = [c for v in seq for c in v]
if path == 'rotation':
out = g.accessor(
struct.pack(f'<{len(flat)}h',
*[max(-32768, min(32767, round(c * 32767)))
for c in flat]),
5122, 'VEC4', len(seq), normalized=True)
else:
out = g.floats(flat, 'VEC3')
samplers_a.append(dict(input=time_acc, output=out,
interpolation='LINEAR'))
channels.append(dict(sampler=len(samplers_a) - 1,
target=dict(node=node, path=path)))
if channels:
anims.append(dict(name=a['name'], channels=channels, samplers=samplers_a))
if anims:
gltf['animations'] = anims
gltf['nodes'] = nodes
gltf['scenes'] = [dict(nodes=[0, skin_node])]
gltf['scene'] = 0
if images:
gltf['images'] = images
gltf['samplers'] = samplers
gltf['textures'] = textures
gltf['materials'] = materials
return g.finish(gltf)
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Raw ROM access for the Pokemon Stadium (US) model export.
Everything here is stdlib-only: byte-order fixup, the Yay0 decompressor, the
PERS-SZP wrapper the assets use, and the little archive format that packs many
files into one segment. That is all it takes to get from baserom.z64 to model
data, so the export does not need `make init`, splat or crunch64.
"""
import hashlib
import struct
# ROM offsets taken from pokestadium-us.yaml.
POKEMON_MODELS = 0x920000 # archive of the 215 battle models
BATTLE_DATA = 0x70D3A0 # per-species battle tables, indexed by D_80075BD0
MAIN_ROM = 0x1000 # main code segment ...
MAIN_VRAM = 0x80000400 # ... and where it lands in RAM
PTR_TABLE_VRAM = 0x80075BD0 # D_80075BD0[species - 1] -> offset into BATTLE_DATA
US_MD5 = 'ed1378bc12115f71209a77844965ba50'
class Rom:
def __init__(self, path):
data = bytearray(open(path, 'rb').read())
magic = struct.unpack_from('>I', data, 0)[0]
if magic == 0x37804012: # .v64, byte-swapped pairs
data[0::2], data[1::2] = data[1::2], data[0::2]
elif magic == 0x40123780: # .n64, word-reversed
data = bytearray(b''.join(data[i:i+4][::-1] for i in range(0, len(data), 4)))
elif magic != 0x80371240: # .z64, native big endian
raise ValueError(f'{path}: not an N64 ROM (magic {magic:#010x})')
self.data = bytes(data)
self.md5 = hashlib.md5(self.data).hexdigest()
@property
def is_expected_us(self):
return self.md5 == US_MD5
def u32(self, o):
return struct.unpack_from('>I', self.data, o)[0]
def vram_to_rom(self, vram):
return MAIN_ROM + (vram - MAIN_VRAM)
# ---- archive ---------------------------------------------------------
def archive(self, off):
"""Segments that hold many files start with
u32 tag, u32 0, u32 totalSize, u32 fileCount
followed by fileCount { u32 offset, u32 size, u32 pad[2] } records,
all relative to the start of the segment (tools/unpack_asset.py).
Only the top three bytes of the first word are reliably zero -- the
model archive puts a nonzero value in the low byte."""
if (self.u32(off) & 0xFFFFFF00) != 0 or self.u32(off + 4) != 0:
return [self.data[off:]]
count = self.u32(off + 12)
if not 0 < count < 4096:
return [self.data[off:]]
out = []
for i in range(count):
rec = off + 0x10 + i * 0x10
start, size = self.u32(rec), self.u32(rec + 4)
out.append(self.data[off + start: off + start + size])
return out
# ------------------------------------------------------------- decompression
def yay0_decompress(src):
"""Nintendo Yay0. Header: magic, decompressed size, link offset, chunk
offset; then a bitstream where a 1 copies one literal byte and a 0 pulls a
(distance, length) pair from the link table."""
if src[:4] != b'Yay0':
raise ValueError('not Yay0')
size, link_off, chunk_off = struct.unpack_from('>3I', src, 4)
out = bytearray(size)
mask_p, link_p, chunk_p, pos = 0x10, link_off, chunk_off, 0
mask, bits = 0, 0
while pos < size:
if bits == 0:
mask = struct.unpack_from('>I', src, mask_p)[0]
mask_p += 4
bits = 32
if mask & 0x80000000:
out[pos] = src[chunk_p]
chunk_p += 1
pos += 1
else:
link = struct.unpack_from('>H', src, link_p)[0]
link_p += 2
dist = link & 0x0FFF
count = link >> 12
if count == 0:
count = src[chunk_p] + 0x12
chunk_p += 1
else:
count += 2
copy = pos - dist - 1
for _ in range(count): # overlapping runs are legal
out[pos] = out[copy]
pos += 1
copy += 1
mask = (mask << 1) & 0xFFFFFFFF
bits -= 1
return bytes(out)
def decompress(blob):
"""Unwrap whatever container an asset arrived in."""
if blob[:8] == b'PERS-SZP':
header = struct.unpack_from('>I', blob, 8)[0]
return yay0_decompress(blob[header:])
if blob[:4] == b'Yay0':
return yay0_decompress(blob)
return blob
def pokemon_models(rom):
"""Returns the decompressed model fragments, indexed by file number."""
return [decompress(b) for b in rom.archive(POKEMON_MODELS)]
+197
View File
@@ -0,0 +1,197 @@
-- data/battle_arenas.lua, edited in place by line.
--
-- The arena editor (tests/arena_editor.lua) writes this file back every time
-- a map is committed, and most of what is in it is PROSE: which alternatives
-- were rejected for each area and what was wrong with them. That argument is
-- the part a tool cannot reconstruct, so nothing here regenerates the file --
-- an entry that changed has its own lines replaced, a new one is appended,
-- and every other byte comes through exactly as it was.
--
-- Kept out of the driver so it can be tested without a game: a bug in here
-- writes a config file, and the failure mode of a bad one is silent -- every
-- map loses its authored spot and battles quietly start happening somewhere
-- else. `apply` therefore never decides on its own that its output is good;
-- it hands back the text and the caller parses it back and checks it says
-- what was asked for before anything is overwritten.
--
-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
-- shape = "narrow" } })
--
-- An edit's value is an entry table, `false` for an authored refusal, or
-- Config.REMOVE to take the entry out altogether.
local Config = {}
Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
-- The heading new maps are collected under, so a file worked over several
-- sessions grows one list rather than one heading per session.
Config.SECTION = " -- ------- added by tests/arena_editor"
-- Lossless both ways: a trailing newline becomes a trailing empty line, and
-- concat puts it back.
function Config.splitLines(text)
local out, pos = {}, 1
while true do
local a, b = text:find("\n", pos, true)
if not a then
out[#out + 1] = text:sub(pos)
break
end
out[#out + 1] = text:sub(pos, a - 1)
pos = b + 1
end
return out
end
-- One entry, laid out the way the file already lays them out: on one line
-- where it fits inside the file's own margin, and wrapped under the brace
-- where it does not (which is how the cross-floor entries are written).
function Config.entryLines(id, entry)
if entry == false then
return { (" [%q] = false,"):format(id) }
end
local head = (" [%q] = { "):format(id)
local parts = {}
if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
parts[#parts + 1] = ("x = %d"):format(entry.x)
parts[#parts + 1] = ("y = %d"):format(entry.y)
parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
if (entry.turn or 0) ~= 0 then
parts[#parts + 1] = ("turn = %d"):format(entry.turn)
end
if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
local one = head .. table.concat(parts, ", ") .. " },"
if #one <= 79 then return { one } end
local first = entry.map and 3 or 2
return {
head .. table.concat(parts, ", ", 1, first) .. ",",
(" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
}
end
-- Where an id's entry starts and ends, plus how many comment lines sit
-- directly above it. The end is the first line that closes the table, so a
-- wrapped entry is found whole rather than half-replaced.
function Config.findEntry(lines, id)
local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
for i = 1, #lines do
if lines[i]:find(pat) then
local note = 0
while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
note = note + 1
end
if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
for j = i, #lines do
if lines[j]:find("}%s*,?%s*$") then return i, j, note end
end
return i, i, note
end
end
return nil
end
-- The `}` that closes the returned table: new entries go in above it.
function Config.closingLine(lines)
for i = #lines, 1, -1 do
if lines[i]:find("^}") then return i end
end
return #lines + 1
end
-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
--
-- Returns the new text and a report: `changed`, `added`, `removed` and
-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
-- entries had comment lines directly above them, and a comment above an
-- entry that has just moved describes where it USED to be.
function Config.apply(text, edits)
local lines = Config.splitLines(text)
local report = { changed = {}, added = {}, removed = {}, stale = {} }
local ordered = {}
for id in pairs(edits) do ordered[#ordered + 1] = id end
table.sort(ordered)
local appended = {}
for _, id in ipairs(ordered) do
local entry = edits[id]
-- found again per edit rather than indexed once up front: each
-- replacement changes the line count under every index after it
local from, to, note = Config.findEntry(lines, id)
if entry == Config.REMOVE then
if from then
for _ = from, to do table.remove(lines, from) end
report.removed[#report.removed + 1] = id
if note > 0 then report.stale[#report.stale + 1] = id end
end
elseif from then
local new = Config.entryLines(id, entry)
for _ = from, to do table.remove(lines, from) end
for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
report.changed[#report.changed + 1] = id
if note > 0 then report.stale[#report.stale + 1] = id end
else
appended[#appended + 1] = id
report.added[#report.added + 1] = id
end
end
if #appended > 0 then
local at, block = nil, {}
for i = 1, #lines do
if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
at = i + 1
end
end
if not at then
at = Config.closingLine(lines)
block[#block + 1] = ""
block[#block + 1] = Config.SECTION
end
for _, id in ipairs(appended) do
for _, l in ipairs(Config.entryLines(id, edits[id])) do
block[#block + 1] = l
end
end
for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
end
return table.concat(lines, "\n"), report
end
-- Whether two entries say the same thing. `false` and nil are values here --
-- a refusal is not the absence of one -- so this is an equality test and not
-- a truthiness one.
function Config.same(a, b)
if a == b then return true end
if type(a) ~= "table" or type(b) ~= "table" then return false end
return a.x == b.x and a.y == b.y
and (a.shape or "wide") == (b.shape or "wide")
and (a.turn or 0) == (b.turn or 0)
and a.cam == b.cam and a.map == b.map
end
-- Parse a config back and check it says what was committed. This is what
-- stands between a bug in the surgery above and a data file that no longer
-- loads, so it is run on the TEXT before that text replaces anything.
-- Returns the parsed table, or nil and why not.
function Config.verify(text, edits, chunkName)
local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
if not chunk then return nil, "would not compile: " .. tostring(err) end
local ok, list = pcall(chunk)
if not (ok and type(list) == "table") then
return nil, "did not return a table: " .. tostring(list)
end
for id, want in pairs(edits) do
local got = list[id]
if want == Config.REMOVE then
if got ~= nil then return nil, id .. " is still in the file" end
elseif not Config.same(want, got) then
return nil, id .. " did not read back as it was committed"
end
end
return list
end
return Config
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
-- Scratch driver: the overworld cuttable tree ($2D/$2E/$3D/$3E, one
-- cell) at PEWTER_CITY (26,4) -- it sits in a gap of the border tree
-- wall, so shoot from the open grass east/west and the path south.
-- Same spots BEFORE and AFTER the pin change.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cuttree_shots.lua \
-- SHOT_DIR=.scratchpad/cuttree AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/cuttree")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[cuttree] DRAMATIC_SHAPE is not loaded")
love.event.quit()
return
end
local V = handle.lib
do -- prove the RUNNING mod sees the pin we think it does
local TS = V.require("TileShape")
local shapes = TS.forMap({ tileset = { id = "OVERWORLD",
imageWidth = 128,
imageHeight = 48 } })
for _, t in ipairs({ 45, 61 }) do
local s = shapes[t]
print("[cuttree] running-mod OVERWORLD tile " .. t .. ": "
.. (s and (tostring(s.class) .. "/" .. tostring(s.art)
.. " h=" .. tostring(s.h)) or "nil"))
end
end
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- open grass west of the tree, edge-on
{ map = "PEWTER_CITY", x = 25, y = 4, face = "right", label = "cut_west" },
-- open grass east of it
{ map = "PEWTER_CITY", x = 27, y = 4, face = "left", label = "cut_east" },
-- the path south, seeing its front over the tree wall gap
{ map = "PEWTER_CITY", x = 26, y = 6, face = "up", label = "cut_front" },
-- one step closer on the gap's south side
{ map = "PEWTER_CITY", x = 26, y = 5, face = "up", label = "cut_near" },
-- Cerulean's lone cut tree at (19,28): open grass to its south
{ map = "CERULEAN_CITY", x = 19, y = 30, face = "up", label = "cer_front" },
{ map = "CERULEAN_CITY", x = 19, y = 29, face = "up", label = "cer_near" },
{ map = "CERULEAN_CITY", x = 20, y = 29, face = "up", label = "cer_diag" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[cuttree] capture missed: " .. path) end
end
else
print("[cuttree] teleport failed: " .. s.map)
end
end
print(("[cuttree] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+95
View File
@@ -0,0 +1,95 @@
-- Scratch driver: the Celadon Diner's stools ($07/$08/$23/$24 on LOBBY,
-- one cell each; the pinned `stool` standee pool). Shot at the voxel
-- rung (5) and the flat rung (3) for orientation, front/back/side of
-- the stool at cell (0,4).
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diner_shots.lua \
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[diner] DRAMATIC_SHAPE is not loaded")
return
end
local V = handle.lib
do -- prove the RUNNING mod resolves the stool tiles as pinned
local TS = V.require("TileShape")
local shapes = TS.forMap({ tileset = { id = "LOBBY",
imageWidth = 128,
imageHeight = 48 } })
for _, t in ipairs({ 7, 8, 23, 24 }) do
local s = shapes[t]
print(("[diner] running-mod tile %d: %s"):format(t,
s and (tostring(s.class) .. "/" .. tostring(s.art)
.. " h=" .. tostring(s.height)) or "nil"))
end
end
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- the stool at (0,4), seen from the south = its front (legs row)
{ map = "CELADON_DINER", x = 0, y = 6, face = "up", label = "stool_front" },
-- the same stool from the north = its back
{ map = "CELADON_DINER", x = 0, y = 2, face = "down", label = "stool_back" },
-- edge-on from the east, with the table beside it in frame
{ map = "CELADON_DINER", x = 2, y = 4, face = "left", label = "stool_side" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[diner] capture missed: " .. path) end
end
else
print("[diner] teleport failed: " .. s.map)
end
end
print(("[diner] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+162
View File
@@ -0,0 +1,162 @@
-- Driver: the DIORAMA mode's own frame, without a headset.
--
-- The diorama is drawn through VoxelScene's `eyes` path, and that path only
-- ever runs from lib/VR -- so with no OpenXR runtime on the machine there is
-- nothing to look at and nothing to check. This builds ONE eye by hand (the
-- same VRRig mapping the headset would have built), opens a diorama frame,
-- and encodes the eye canvas straight to a PNG.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diorama_shots.lua \
-- SHOT_DIR=<dir> lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory under the LOVE save dir (default "diorama")
-- DIO_MAP map id (default VIRIDIAN_CITY)
-- DIO_SPOT "x,y[,facing]" (default 20,26,up)
-- DIO_RUNG the voxel camera rung (default 5, the 75 one)
--
-- It also prints whether each shader the mode touches actually COMPILED,
-- which is the part a headless suite cannot answer: the cull is new source
-- in the scene shader, the water shader and both forest ones, and a shader
-- that will not build fails soft everywhere in this mod -- the picture just
-- quietly loses a pass.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = os.getenv("SHOT_DIR") or "diorama"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[dio] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
return
end
local V = handle.lib
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local VoxelScene = V.require("VoxelScene")
local VRRig = V.require("VRRig")
local Diorama = V.require("Diorama")
local Water = V.require("Water")
local ChunkMesher = V.require("ChunkMesher")
local DayNight = V.require("DayNight")
local VR = V.require("VR")
local Curve = V.require("WorldCurve")
local MAP = os.getenv("DIO_MAP") or "VIRIDIAN_CITY"
local SPOT = os.getenv("DIO_SPOT") or "20,26,up"
local RUNG = math.floor(tonumber(os.getenv("DIO_RUNG")) or 5)
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
if sf == "" then sf = "up" end
require("src.world.OverworldController").rollEncounter = function() return nil end
DayNight.setting:sync("day")
U.teleport(game, MAP, sx, sy, sf)
Pipelines.setLevel("voxel", RUNG)
Pipelines.setLevel("tiltshift", 0)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
break
end
U.wait(1)
end
U.wait(20)
end
settle()
settle()
print(("[dio] scene shader %s | grid variant %s | water %s")
:format(tostring(Voxel3D.shader() ~= nil),
tostring(Voxel3D.shader(true) ~= nil),
tostring(Water.shader(false) ~= nil)))
-- one eye, straight ahead from the resting head: the mapping lib/VR would
-- have built for this rung, with no pose to read off a runtime
local POSE = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } }
local FOV = { angleLeft = -0.8, angleRight = 0.8,
angleUp = 0.7, angleDown = -0.7 }
local function shoot(name, opts)
local ow = game.overworld
local vw, vh = 320, 288
pcall(function() vw, vh = game.renderer:worldViewSize() end)
local cx = ow.camera.x + vw / 2
local cy = ow.camera.y + vh / 2
Diorama.begin(opts.mode or "diorama")
Diorama.zoom = opts.zoom or 1
Diorama.yaw = opts.yaw or 0
-- the cut's SHAPE is the V-CURVE row's (box while flat, ball while
-- bent), so the driver throws the row rather than asking for a shape
Curve.setting:sync(opts.curve or 0)
if opts.arena then
Diorama.pillar(opts.arena)
else
Diorama.viewport(cx, cy, vh)
end
local pivot = VRRig.dioramaPivot(Diorama.cull.x, Diorama.cull.z)
local anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
-- the same framing lib/VR picks: the view ordinarily, the DISC while a
-- fight is staged
local frame = opts.arena and (Diorama.cull.r * 2.6) or vh
local scale = VRRig.dioramaScale(frame, Voxel.FOCAL)
-- the bend the diorama asks its eyes for (lib/VR does the same)
local curveK = Curve.k(vh)
local eyes = {
{ camera = VRRig.eyeCamera(POSE, FOV, pivot, anchor, scale,
opts.yaw ~= 0 and opts.yaw or nil, curveK),
w = 720, h = 720, slot = "vrL", adopt = false },
cx = pivot[1], cy = pivot[3],
}
VoxelScene.spriteLean = math.rad(75)
local ok, out = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
VR.paletteFor, eyes)
VoxelScene.spriteLean = nil
if not (ok and out and out[1]) then
print("[dio] " .. name .. ": no frame (" .. tostring(out) .. ")")
Diorama.stop()
return
end
local okE, err = pcall(function()
love.filesystem.createDirectory(ROOT)
local data = out[1]:newImageData()
data:encode("png", ROOT .. "/" .. name .. ".png")
end)
print("[dio] " .. name
.. (okE and " written" or (" ENCODE FAILED: " .. tostring(err))))
Diorama.stop()
end
shoot("box", { mode = "diorama" })
shoot("box-turned", { mode = "diorama", yaw = math.rad(35) })
shoot("box-tight", { mode = "diorama", zoom = 0.5 })
shoot("ball-3", { mode = "diorama", curve = 3 })
shoot("ball-4", { mode = "diorama", curve = 4 })
shoot("ball-5", { mode = "diorama", curve = 5 })
shoot("ball-wide", { mode = "diorama", curve = 5, zoom = 1.9 })
shoot("keyed", { mode = "diorama-mr" })
shoot("keyed-ball", { mode = "diorama-mr", curve = 3 })
do
-- a fight's disc, cut about the arena the map would actually stage on
local BattleArena = V.require("BattleArena")
local ow = game.overworld
local arena = BattleArena.find(ow.map, ow.player.cellX, ow.player.cellY,
false)
if arena then
shoot("arena-disc", { mode = "diorama", arena = arena })
else
print("[dio] no arena on this map -- disc shot skipped")
end
end
Diorama.stop()
print("[dio] done; PNGs are under the LOVE save directory / " .. ROOT)
love.event.quit()
end
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
-- Driver: screenshot the forest's atmosphere.
--
-- Viridian Forest through the pins: gold shafts and drifting pollen by
-- day, silver moon rays and fireflies at night, the haze under both, one
-- first-person frame standing inside a beam, and the control shots -- the
-- row OFF (which must be pixel-identical to a run before the feature
-- existed) and LOW (haze, half the shafts, no particles).
--
-- Deterministic on purpose: encounters killed, tile animation frozen, the
-- atmosphere's own clock pinned, so an AB_TAG=before/after pair diffs
-- clean (see tools/voxel-survey.md for the workflow).
--
-- SHOT_DIR=.scratchpad/forestfog AB_TAG=after \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/forest_fog_shots.lua lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/forestfog")
.. "/" .. (os.getenv("AB_TAG") or "after")
local exports = game.mods and game.mods.exports
local handle = exports and exports.DRAMATIC_SHAPE
if not (handle and handle.lib) then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local lib = handle.lib
local DayNight = lib.require("DayNight")
local ChunkMesher = lib.require("ChunkMesher")
local Voxel = lib.require("VoxelState")
local ForestAtmos = lib.require("ForestAtmos")
if not (game.data.maps and game.data.maps.VIRIDIAN_FOREST) then
U.log("no VIRIDIAN_FOREST in this dataset")
return
end
-- ------- the determinism recipe (see round_regress_shots)
require("src.world.OverworldController").rollEncounter =
function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
-- pin the atmosphere's own clock: every shimmer and every mote is a
-- pure function of this number
ForestAtmos.frozen = true
ForestAtmos.time = 5
local function setTime(value)
DayNight.setting:sync(value)
DayNight.update(0)
end
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
break
end
U.wait(1)
end
U.wait(40)
end
local function go(x, y, face, rung)
U.teleport(game, "VIRIDIAN_FOREST", x, y, face or "up")
Pipelines.setLevel("voxel", rung or 5)
Pipelines.setLevel("tiltshift", 0) -- judge the atmosphere, not the blur
settle()
end
ForestAtmos.setting:sync("full")
-- ------- day: gold spears and pollen, three vantages
setTime("day")
go(17, 20)
U.shot(game, ROOT .. "/10_day_17_20.png")
go(16, 20)
U.shot(game, ROOT .. "/11_day_16_20.png")
go(16, 24)
U.shot(game, ROOT .. "/12_day_16_24.png")
-- ------- night: moon rays and fireflies, same vantages
setTime("night")
go(17, 20)
U.shot(game, ROOT .. "/20_night_17_20.png")
go(16, 20)
U.shot(game, ROOT .. "/21_night_16_20.png")
go(16, 24)
U.shot(game, ROOT .. "/22_night_16_24.png")
-- ------- first person, standing in a beam
--
-- The shafts are dealt by seed, so ask the layout where one landed and
-- walk into it rather than guessing a cell.
local shaft
pcall(function()
local map = game.overworld and game.overworld.map
local L = map and ForestAtmos.layoutFor(map)
shaft = L and L.shafts and L.shafts[1]
end)
if shaft then
local cx = math.floor(shaft.x / 16)
local cy = math.floor(shaft.z / 16)
setTime("day")
go(cx, cy + 1, "up", 6) -- the 1ST rung, facing the beam
U.shot(game, ROOT .. "/30_fp_in_beam_day.png")
setTime("night")
U.wait(30)
U.shot(game, ROOT .. "/31_fp_in_beam_night.png")
else
U.log("no shaft landed -- check the layout seed")
end
-- ------- the controls
setTime("day")
ForestAtmos.setting:sync("low")
go(17, 20)
U.shot(game, ROOT .. "/40_low_day.png")
ForestAtmos.setting:sync("off")
go(17, 20)
U.shot(game, ROOT .. "/41_off_day.png") -- must match a pre-feature run
ForestAtmos.setting:sync("full")
ForestAtmos.frozen = false
setTime("day")
U.log("done -- " .. ROOT)
end
+166
View File
@@ -0,0 +1,166 @@
-- Probe: does WASD still walk CAMERA-RELATIVE on the 1ST/3RD rungs?
--
-- The regression report is "in first and third person the wasd keys now
-- move in cardinal directions". Cardinal means the yaw is not being
-- applied -- either FreeMove.tick is not the handler that ran (so the
-- engine's grid walk did, which is cardinal by construction), or it ran
-- and moveWorld got a yaw of zero.
--
-- So measure both: which handler took the frame, what the yaw was, and
-- which way the player actually travelled for a held W.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/freemove_probe.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
U.log("DRAMATIC_SHAPE is not loaded")
return love.event.quit()
end
local V = handle.lib
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local Voxel = V.require("VoxelState")
local ChunkMesher = V.require("ChunkMesher")
require("src.world.OverworldController").rollEncounter = function() return nil end
-- FirstPerson captures the mouse only with WINDOW FOCUS, and a driver
-- window never has it -- so without this the look reads as dead for a
-- reason that has nothing to do with the code under test. Force the
-- answer it gates on; setRelativeMode then arms and the relative-motion
-- wrap claims the deltas exactly as it would for a player.
love.window.hasFocus = function() return true end
-- count which walk handler actually takes the frames
local ticks = 0
local innerTick = FreeMove.tick
FreeMove.tick = function(...)
ticks = ticks + 1
return innerTick(...)
end
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(60)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if FirstPerson.blend >= 1 and Voxel.ready then break end
U.wait(1)
end
U.wait(30)
end
-- W is the UP button; the B button's key is "x" (see Input's
-- DEFAULT_BINDINGS -- the driver must press KEYS, not button names, to
-- exercise the real path)
local function holdKey(k, frames)
love.keypressed(k, k, false)
U.wait(frames)
love.keyreleased(k, k)
U.wait(4)
end
for _, rung in ipairs({ { "1ST", Voxel.FP_LEVEL }, { "3RD", Voxel.TP_LEVEL } }) do
Pipelines.setLevel("voxel", rung[2])
settle()
-- face EAST: yaw is the free-roam look, and a camera-relative W must
-- then walk +X. A cardinal W walks -Y (north) whatever the camera does.
FirstPerson.yaw = math.pi / 2
U.wait(10)
local ow = game.stack:top()
local p = ow and ow.player
if not p then U.log(rung[1] .. ": no player") break end
local x0, y0 = p.px, p.py
ticks = 0
holdKey("w", 40)
local dx, dy = p.px - x0, p.py - y0
U.log(("%s: driving=%s freeMove ticks=%d yaw=%.2f W moved dx=%.1f dy=%.1f")
:format(rung[1], tostring(FirstPerson.driving()), ticks,
FirstPerson.yaw, dx, dy))
local wx, wz = FirstPerson.moveWorld(0, 1)
U.log(("%s: moveWorld(0,1) = %.2f,%.2f (want a mostly-X vector at this yaw)")
:format(rung[1], wx, wz))
-- ------- and does the LOOK still turn?
--
-- A yaw that never moves is the same symptom from the player's seat:
-- it stays at the cardinal angle the rung was entered on (FACING_ANGLE
-- is one of four compass points), so W walks due north for ever and
-- "wasd moves in cardinal directions" is exactly what it feels like.
-- The capture mode's pointer wraps are installed OUTSIDE FirstPerson's,
-- so this is the path that could have regressed.
-- FirstPerson only claims relative motion while it has CAPTURED the
-- mouse, and it captures only with window focus. A driver window that
-- never got focus would show a dead look for a reason that has nothing
-- to do with the code -- so record the discriminator rather than read
-- a zero and blame the wrap.
local okF, focus = pcall(function() return love.window.hasFocus() end)
local okR, rel = pcall(function() return love.mouse.getRelativeMode() end)
U.log(("%s: engaged=%s focus=%s relativeMode=%s")
:format(rung[1], tostring(FirstPerson.engaged()),
okF and tostring(focus) or "?",
okR and tostring(rel) or "?"))
local before = FirstPerson.yaw
for _ = 1, 10 do
love.mousemoved(400, 300, 12, 0, false)
U.wait(1)
end
U.wait(4)
U.log(("%s: mouse look -- yaw %.3f -> %.3f (delta %.3f)%s")
:format(rung[1], before, FirstPerson.yaw, FirstPerson.yaw - before,
math.abs(FirstPerson.yaw - before) < 1e-6
and " <-- THE LOOK IS DEAD" or ""))
end
-- ------- and now the way a PLAYER gets there: the "3" hotkey
--
-- Pipelines.setLevel above is the driver's shortcut. A player cycles the
-- rung with 3, which goes through the mod's own cycleVoxel. If that
-- leaves Voxel.level disagreeing with the pipeline's level, the camera
-- can be first-person while FreeMove.engaged() says no -- and then the
-- ENGINE's grid handler takes the frame, which walks cardinally. That is
-- the reported symptom exactly, so it is worth entering the rung the
-- same way the report did.
Pipelines.setLevel("voxel", 0)
U.wait(20)
for i = 1, 8 do
love.keypressed("3", "3", false)
U.wait(3)
love.keyreleased("3", "3")
U.wait(12)
local lvl = Pipelines.level("voxel")
U.log(("hotkey 3 x%d -> pipeline level=%s Voxel.level=%s freeCam=%s "
.. "engaged=%s driving=%s")
:format(i, tostring(lvl), tostring(Voxel.level),
tostring(Voxel.isFreeCam(Voxel.level)),
tostring(FirstPerson.engaged()),
tostring(FirstPerson.driving())))
if Voxel.isFreeCam(Voxel.level) then
settle()
local ow = game.stack:top()
local p = ow and ow.player
FirstPerson.yaw = math.pi / 2
U.wait(6)
local yaw0 = FirstPerson.yaw
local x0, y0 = p.px, p.py
ticks = 0
holdKey("w", 30)
U.log((" walked from the HOTKEY rung: ticks=%d yaw=%.2f dx=%.1f dy=%.1f%s")
:format(ticks, yaw0, p.px - x0, p.py - y0,
ticks == 0 and " <-- ENGINE GRID WALK (cardinal)" or ""))
end
end
FreeMove.tick = innerTick
U.log("done")
end
+367
View File
@@ -0,0 +1,367 @@
-- Driver: the two edges the capture mode has to hold on to.
--
-- A FULL with an EMPTY BAG. The encounter must still be a Let's Go
-- encounter -- head-on seat, no player Pokemon, no classic menu --
-- with the readout saying there is nothing to throw and RUN as the
-- way out. The failure this catches is the old behaviour: falling
-- back to the battle menu, which under FULL offers a FIGHT against a
-- foe that never takes a turn.
--
-- B FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to
-- fall short: the miss must land in the empty hand rather than
-- tearing the session down.
--
-- C The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK
-- and the PIKACHU are the same makeOldManDemo). LET'S GO must not
-- touch it at any rung -- no session, no held camera, no veil -- and
-- it must play its scripted throw through to its own ending.
--
-- SHOT_DIR=.scratchpad/letsgo \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Bag = require("src.inventory.Bag")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local LetsGo = lib.require("LetsGo")
local CatchThrow = lib.require("CatchThrow")
local BattleScene = lib.require("BattleScene")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 10),
}
game.save.player.name = "RED"
LetsGo.setting:setValue("full", game)
if os.getenv("DS_RUNG") == "cards" then
lib.require("OverworldBattle").setting:setValue(true, game)
U.log("3D-BTL forced to 2D-3D A")
end
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
-- every ball out of the bag, whatever the save arrived with
local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
local function emptyBag()
for _, id in ipairs(BALLS) do
local n = game.save.inventory[id] or 0
if n > 0 then pcall(Bag.remove, game.save, id, n) end
game.save.inventory[id] = nil
end
end
local function ballCount()
local n = 0
for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end
return n
end
-- A REAL key event, not U.tap's synthetic pressQueue inject. The bug
-- this driver has to be able to see (button edges read on the render
-- clock instead of the logic step) lives between love.keypressed and
-- whoever polls the edge, so a driver that writes the queue itself
-- jumps straight over it.
--
-- The B BUTTON's keyboard binding is "x" (or backspace) -- Input's
-- DEFAULT_BINDINGS. The `b = "b"` next to it is the GAMEPAD table, so a
-- driver that presses the "b" KEY presses nothing at all and reports a
-- dead button whatever the code does.
local B_KEY = "x"
local function key(name)
love.keypressed(name, name, false)
U.wait(2)
love.keyreleased(name, name)
U.wait(2)
end
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(90)
-- drive the intro chatter until the menu would open (which under FULL is
-- when capture mode takes over instead)
local function toCapture(battle)
U.wait(70)
for _ = 1, 80 do
if battle.phase == "menu" or CatchThrow.session() then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(30)
end
-- Tap the fight all the way off the stack before the next case starts.
-- Cases that merely tapped A a fixed number of times left the previous
-- battle (and its session) alive whenever an outcome ran long, and the
-- next case then measured the leftover -- which reads as that case
-- failing, at a spot nowhere near the cause.
local function closeOut(battle)
for _ = 1, 400 do
if not CatchThrow.session() and game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(4)
end
U.wait(40)
end
local function describe(tag, battle)
local s = CatchThrow.session()
U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
:format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-",
s and s.phase or "-", tostring(battle.phase),
BattleScene.capture and "up" or "down",
tostring(BattleScene.capture
and BattleScene.capture.hidePlayer)))
return s
end
-- ------- A: an empty bag still gets the capture screen
emptyBag()
U.log("== A: FULL with " .. ballCount() .. " balls")
local a = BattleState.newWild(game, "PIDGEY", 5)
a.onFinish = function() end
game.overworld:pushBattle(a)
toCapture(a)
local s = describe("A", a)
U.log(("A: hand is empty -> %s (want yes)"):format(
(s and s.empty) and "yes" or "NO"))
U.shot(game, DIR .. "/empty_1_aim.png")
-- and B is the way out: a Let's Go wild always escapes
key(B_KEY)
for _ = 1, 120 do
U.wait(2)
if a.result then break end
U.tap(game, "a")
end
U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result)))
-- the session and the held camera are swept by battle.ended, which is the
-- teardown BELOW this, not the moment `result` is written -- so the sweep
-- is only worth asserting once the battle has actually left the stack
closeOut(a)
U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
:format(CatchThrow.session() and "still up" or "gone",
BattleScene.capture and "still up" or "down"))
-- ------- B: the LAST ball, thrown short
Bag.add(game.save, "POKE_BALL", 1, game.data)
CatchThrow.lastBall = "POKE_BALL"
U.log("== B: FULL with " .. ballCount() .. " ball")
local b = BattleState.newWild(game, "PIDGEY", 5)
b.onFinish = function() end
-- The outcome under test is what happens when the LAST ball MISSES, so
-- the roll must not be left to chance: a run where the Pidgey happened
-- to be caught used to cascade into every later case (the session lived
-- on into its epilogue, and C and D then measured that leftover instead
-- of their own). Forced failure, three shakes -- the roll is the
-- engine's business and is covered elsewhere.
b.catchAttempt = function() return false, 3 end
game.overworld:pushBattle(b)
toCapture(b)
describe("B", b)
-- a deliberately feeble swipe: it must fall short of the Pokemon, so
-- the miss is the outcome under test rather than a lucky catch
local aim = CatchThrow._aimInfo()
if aim then
local uw, uh = love.graphics.getDimensions()
local function toWin(gx, gy)
return (aim.lx + gx * aim.scale) * uw / aim.pw,
(aim.ly + gy * aim.scale) * uh / aim.ph
end
local hx, hy = aim.hand[1], aim.hand[2]
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
local d = math.sqrt(dx * dx + dy * dy)
dx, dy = dx / d, dy / d
local step = 120 / 60
local px, py = toWin(hx, hy)
love.mousepressed(px, py, 1, false, 1)
for i = 1, 8 do
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
love.mousemoved(wx, wy, 0, 0, false)
U.wait(1)
end
local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8)
love.mousereleased(wx, wy, 1, false, 1)
else
U.log("B: NO AIM INFO -- capture mode did not open")
end
-- ride the throw out, then keep tapping through the miss text until the
-- hand is refilled -- which, with the bag now empty, means the EMPTY hand
for _ = 1, 400 do
U.wait(2)
local q = CatchThrow.session()
if q and q.phase == "aim" and q.empty then break end
if not q then break end
if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end
end
local sb = describe("B", b)
U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)")
:format(ballCount(),
(sb and sb.empty) and "empty hand" or
(sb and ("still holding " .. tostring(sb.ballId)))
or "SESSION GONE"))
U.shot(game, DIR .. "/empty_2_ranout.png")
key(B_KEY)
for _ = 1, 120 do
U.wait(2)
if b.result then break end
U.tap(game, "a")
end
U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
closeOut(b)
-- ------- C: the scripted tutorial, untouched
Bag.add(game.save, "POKE_BALL", 10, game.data)
U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls")
local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
local c = BattleState.newWild(game, om.species, om.level)
c:makeOldManDemo()
c.onFinish = function() end
U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c))
.. " fullWild -> " .. tostring(LetsGo.fullWild(c))
.. " wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c)))
game.overworld:pushBattle(c)
-- the demo drives ITSELF: the cursor, the bag and the throw are all
-- scripted, so this only watches. Any session or veil appearing here is
-- the failure.
local sawSession, sawVeil = false, false
local shotDemo = false
for i = 1, 900 do
if CatchThrow.session() then sawSession = true end
if BattleScene.capture then sawVeil = true end
if not shotDemo and i > 200 then
shotDemo = true
U.shot(game, DIR .. "/empty_3_oldman.png")
end
if c.result then break end
U.wait(2)
-- the scripted beats want A only to page the text along
if i % 3 == 0 then U.tap(game, "a") end
end
U.log(("C: session ever opened=%s (want no) veil ever up=%s (want no)")
:format(tostring(sawSession), tostring(sawVeil)))
U.log(("C: result=%s balls=%d (want 10 -- the demo consumes none)")
:format(tostring(c.result), ballCount()))
U.log(("C: party still %d, first is %s")
:format(#game.save.party,
game.data.pokemon[game.save.party[1].species].name))
-- ------- D: B RUNS, with a ball in hand, on a frame that runs several
-- logic steps
--
-- The reported failure, reproduced rather than reasoned about. Input:step
-- rebuilds the edge table once per FIXED step; Game:update runs the
-- frame's steps first and the render-clock hooks after, so any frame
-- carrying more than one step has already discarded the earlier steps'
-- edges. Below 60fps -- which is where a 3D battle lives -- that is
-- every press. speedOverride multiplies the logic clock, so it packs
-- several steps into each frame on demand and turns "sometimes, on a
-- slow machine" into "every time, here".
for _, speed in ipairs({ 1, 4 }) do
Bag.add(game.save, "POKE_BALL", 5, game.data)
CatchThrow.lastBall = "POKE_BALL"
game.speedOverride = speed > 1 and speed or nil
U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed))
local d = BattleState.newWild(game, "PIDGEY", 5)
d.onFinish = function() end
game.overworld:pushBattle(d)
toCapture(d)
local sd = describe("D" .. speed, d)
if sd and not sd.empty then
-- where does the key actually get to? Each stage of the chain, so a
-- dead B is attributed rather than guessed at
local top = game.stack and game.stack:top()
U.log(("D%dX probe: top=%s onKeyPressed=%s fullWild=%s declinable=%s")
:format(speed, tostring(top and top.screenId or "?"),
tostring(top and top.onKeyPressed ~= nil),
tostring(sd.fullWild), tostring(sd.declinable)))
-- ------- the counter-factual, measured rather than argued
--
-- How many times an edge would have been visible to a poll on the
-- RENDER clock -- where these reads used to live. Measured on UP
-- rather than B: the fix TAKES B out of the queue, so B never
-- reaches `pressed` any more and would read as a false zero. UP is
-- untouched by everything while the battle is parked, and the
-- question is about step-vs-frame ordering, not about which button.
local onFrame, onStep = 0, 0
local innerU = CatchThrow.update
CatchThrow.update = function(dt)
if game.input.wasPressed and game.input:wasPressed("up") then
onFrame = onFrame + 1
end
return innerU(dt)
end
local innerB0 = CatchThrow.buttons
CatchThrow.buttons = function(g)
local q = g and g.input and g.input.pressQueue
if q then
for i = 1, #q do if q[i] == "up" then onStep = onStep + 1 end end
end
return innerB0(g)
end
love.keypressed("up", "up", false)
U.wait(2)
love.keyreleased("up", "up")
U.wait(2)
CatchThrow.buttons = innerB0
CatchThrow.update = innerU
U.log(("D%dX counter-factual: one UP press -- the logic step saw it "
.. "%d time(s), the RENDER clock %d time(s)%s")
:format(speed, onStep, onFrame,
onFrame == 0 and " <-- a frame poll misses it entirely"
or ""))
local seen = 0
local innerB = CatchThrow.buttons
CatchThrow.buttons = function(g)
local q = g and g.input and g.input.pressQueue
if q and #q > 0 then
seen = seen + 1
U.log("D probe: buttons saw queue [" .. table.concat(q, ",") .. "]")
end
return innerB(g)
end
love.keypressed(B_KEY, B_KEY, false)
U.log(("D probe: right after keypressed -- queue=[%s] state.b=%s")
:format(table.concat(game.input.pressQueue, ","),
tostring(game.input.state.b)))
U.wait(2)
love.keyreleased(B_KEY, B_KEY)
U.wait(2)
CatchThrow.buttons = innerB
U.log("D probe: buttons saw a non-empty queue " .. seen .. " time(s)")
local ran = false
for _ = 1, 60 do
U.wait(2)
if d.result then ran = true break end
end
U.log(("D%dX: after a REAL B -- result=%s (want run) %s")
:format(speed, tostring(d.result),
ran and "RAN" or "*** B DID NOTHING ***"))
else
U.log("D" .. speed .. ": no armed session to test")
end
-- escape hatch: a B that did nothing leaves the battle parked in the
-- capture phase forever, and the rest of the run must still report
if not d.result then
pcall(CatchThrow.onBattleEnded)
d.result, d.phase, d.afterQueue = "run", "messages", "finish"
end
closeOut(d)
game.speedOverride = nil
end
U.log("done -- " .. DIR)
end
+104
View File
@@ -0,0 +1,104 @@
-- Probe: the reported grass along the TOP of the frame in GO-style
-- battles on ROUTE 6.
--
-- The capture seat is a different camera from the battle's own: the "tele"
-- rig stands 145 world px back at a height of 37.9 (well over two cells,
-- clear of anything that grows on the ground), while the head-on capture
-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass
-- tuft or hedge. On a route lined with the stuff, the eye is inside it.
--
-- So: stage a capture at several spots along Route 6 and record where the
-- eye actually is relative to the ground, with a shot of each.
--
-- SHOT_DIR=.scratchpad/route6 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Bag = require("src.inventory.Bag")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded")
return
end
local LetsGo = lib.require("LetsGo")
local CatchThrow = lib.require("CatchThrow")
local ChunkMesher = lib.require("ChunkMesher")
pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul')
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
game.save.player.name = "RED"
Bag.add(game.save, "POKE_BALL", 99, game.data)
CatchThrow.lastBall = "POKE_BALL"
LetsGo.setting:setValue("full", game)
local MAP = os.getenv("DS_MAP") or "ROUTE_6"
-- a spread down the route: the reported shot is on the path with hedges
-- both sides, which is where a low seat has the least room
local SPOTS = {}
for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do
SPOTS[#SPOTS + 1] = xy
end
for i, sp in ipairs(SPOTS) do
U.teleport(game, MAP, sp[1], sp[2], "up")
U.wait(50)
-- confirm the teleport actually landed: a first cut reported the SAME
-- eye at all five spots, which meant the probe never left the save's
-- own position and every "spot" was one place wearing five labels
do
local ow = game.overworld
local m = ow and ow.map
U.log(("spot %d: on map %s at cell %s,%s")
:format(i, tostring(m and (m.id or m.name) or "?"),
tostring(ow and ow.player and ow.player.cellX),
tostring(ow and ow.player and ow.player.cellY)))
end
for _ = 1, 600 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
local bt = BattleState.newWild(game, "ODDISH", 13)
bt.onFinish = function() end
game.overworld:pushBattle(bt)
U.wait(70)
for _ = 1, 80 do
if bt.phase == "menu" or CatchThrow.session() then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(40)
local s = CatchThrow.session()
if s and s.shot and s.shot.eye then
local e = s.shot.eye
local p = s.playerPos
local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2)
U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f height %.1f over ground "
.. "seat %.1f back (wants 46)%s")
:format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back,
back < 45.5 and " <-- the world pulled it in" or ""))
else
U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2]))
end
U.shot(game, ("%s/spot%d.png"):format(DIR, i))
-- out, and all the way off the stack before the next spot
U.tap(game, "b")
for _ = 1, 300 do
if not CatchThrow.session() and game.stack:top() ~= bt then break end
U.tap(game, "a")
U.wait(4)
end
U.wait(30)
end
U.log("done -- " .. DIR)
end
+309
View File
@@ -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
+163
View File
@@ -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
+102
View File
@@ -0,0 +1,102 @@
-- Scratch driver: the Game Freak office computer desks (buildings
-- template mansion_computer_desk). CELADON_MANSION_2F cell (0,5) and
-- CELADON_MANSION_3F cells (0,3)/(3,3)/(0,6). Shot at the voxel rung
-- (5), the mid rung (4) and the flat rung (3) for orientation.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_desk_shots.lua \
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
-- AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
local TAG = os.getenv("AB_TAG") or "shot"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[deskshot] DRAMATIC_SHAPE is not loaded")
return
end
local V = handle.lib
do -- prove the RUNNING mod's profile carries the new template
local ok, s = pcall(V.data, "voxel_heights")
local found = "NO"
if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
for _, t in ipairs(s.buildings.MANSION) do
if t.id == "mansion_computer_desk" then
found = ("YES fascia=%d-%d base=%d-%d parts=%d")
:format(t.desk.fascia[1], t.desk.fascia[2],
t.desk.base[1], t.desk.base[2], #t.parts)
end
end
end
print("[deskshot] running-mod mansion_computer_desk: " .. found)
end
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- 2F: the lone desk at (0,5):(1,6). Stand just south of it, facing
-- north, so the camera looks along the desk's front face.
{ map = "CELADON_MANSION_2F", x = 1, y = 7, face = "up", label = "desk2f" },
-- and from the east, to see the drawer pedestal and the chair in
-- profile against the checker floor
{ map = "CELADON_MANSION_2F", x = 2, y = 6, face = "left", label = "desk2f_side" },
-- 3F: the pair at (0,3) and (3,3), both in frame from between them
{ map = "CELADON_MANSION_3F", x = 2, y = 5, face = "up", label = "desk3f_pair" },
-- 3F: the south desk at (0,6), close in
{ map = "CELADON_MANSION_3F", x = 1, y = 8, face = "up", label = "desk3f" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 4, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[deskshot] capture missed: " .. path) end
end
else
print("[deskshot] teleport failed: " .. s.map)
end
end
print(("[deskshot] %d shots into %s (tag %s)"):format(shots, ROOT, TAG))
love.event.quit()
end
+91
View File
@@ -0,0 +1,91 @@
-- Scratch driver: the CELADON_MANSION_1F square table (buildings
-- template mansion_square_table, cells (0,6):(1,7)). Shot at the voxel
-- rung (5) and the flat rung (3) for orientation, front/back/side.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_shots.lua \
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
-- "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[mansion] DRAMATIC_SHAPE is not loaded")
return
end
local V = handle.lib
do -- prove the RUNNING mod's profile carries the new template
local ok, s = pcall(V.data, "voxel_heights")
local found = false
if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
for _, t in ipairs(s.buildings.MANSION) do
if t.id == "mansion_square_table" then found = true end
end
end
print("[mansion] running-mod mansion_square_table: " .. tostring(found))
end
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- the rung-5 camera looks north from ~4 cells south of the player, so
-- the room's 16-voxel south wall hides the 6-voxel table at the low
-- rung; rungs 4 and 3 pitch over it. Stand just south of the table.
{ map = "CELADON_MANSION_1F", x = 1, y = 8, face = "up", label = "sqtable" },
{ map = "CELADON_MANSION_1F", x = 2, y = 6, face = "left", label = "sqtable_beside" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 4, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[mansion] capture missed: " .. path) end
end
else
print("[mansion] teleport failed: " .. s.map)
end
end
print(("[mansion] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+83
View File
@@ -0,0 +1,83 @@
-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit
-- rungs, to see what the roof surface wears down a TAPERED flank.
--
-- A tapered column's first drawn row is its silhouette cap, and the cap is
-- outline black. The depth map spends most of the roof's depth above that
-- cap, so a surface clamped onto it paints the outline the length of the
-- slope and the course cycle beats against it -- black teeth marching down
-- the hip. The surface belongs on the column's first PAINTED row instead.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \
-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcap")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[cap] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(30)
end
-- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the
-- two tapered roof bands standing on one map -- 16 drawn rows and 32,
-- the two groups every other sloped building borrows its band table
-- from. Standing south of each puts the hip end across the frame.
local SCENES = {
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" },
{ map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" },
{ map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(8)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[cap] capture missed: " .. path) end
end
end
print(("[cap] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+96
View File
@@ -0,0 +1,96 @@
-- Scratch driver: the Safari Zone's decorations — the "cut"-style round
-- trees ($54/$55/$56/$57, one cell) and the stump/bush rows ($02/$03/
-- $12/$13) — front and back, at the voxel rung (5) and the flat rung (3)
-- for orientation. Same spots BEFORE and AFTER the pin change.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/safari_shots.lua \
-- SHOT_DIR=.scratchpad/safari AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/safari")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[safari] DRAMATIC_SHAPE is not loaded")
return
end
local V = handle.lib
do -- what does the RUNNING mod think tile 84 on FOREST is?
local TS = V.require("TileShape")
local shapes = TS.forMap({ tileset = { id = "FOREST",
imageWidth = 128,
imageHeight = 48 } })
local s = shapes[84]
print("[safari] running-mod tile 84: "
.. (s and (tostring(s.class) .. "/" .. tostring(s.art)
.. " authored=" .. tostring(s.authored)) or "nil"))
end
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- tree row at (2..5, 3), seen from the south = its front
{ map = "SAFARI_ZONE_EAST", x = 4, y = 5, face = "up", label = "trees_front" },
-- the same row from the north = its back
{ map = "SAFARI_ZONE_EAST", x = 4, y = 1, face = "down", label = "trees_back" },
-- the long tree line at (2..9, 6)
{ map = "SAFARI_ZONE_EAST", x = 6, y = 8, face = "up", label = "treeline" },
-- the stump/bush row along the top edge (8..19, 0)
{ map = "SAFARI_ZONE_EAST", x = 12, y = 2, face = "up", label = "stumps_front" },
-- Safari Center's west tree column, edge-on
{ map = "SAFARI_ZONE_CENTER", x = 2, y = 4, face = "left", label = "center_trees" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[safari] capture missed: " .. path) end
end
else
print("[safari] teleport failed: " .. s.map)
end
end
print(("[safari] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+114
View File
@@ -0,0 +1,114 @@
-- Driver: five shiny encounters, five outdoor places, paced for a capture.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_obs.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- Run from the PROJECT ROOT. Nothing here is a screenshot test -- it is a
-- performance, timed for somebody recording the window.
--
-- Each beat: teleport, let the ground mesh, open a wild battle, let the
-- Pokemon ARRIVE (the wipe, the send-out, the shiny sparkle), then hold
-- three full seconds on it before closing the battle and moving on. The hold
-- is deliberately silent -- no button taps during it -- so the recording is
-- of the Pokemon standing there and not of a text box being clicked through.
--
-- Every encounter is shiny, because the odds are pinned to 1 for the run and
-- the roll still happens where it always does, inside Pokemon.new.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- nothing to show")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
-- ------- timing, in frames at 60fps
local SETTLE = 110 -- after a teleport, for the neighbourhood to mesh
local ARRIVE = 70 -- the wipe, then the Pokemon landing on its tile
local HOLD = 180 -- THE THREE SECONDS
local BETWEEN = 45 -- back on the map before the next one opens
-- ------- the models
if not StadiumInstall.ready() then
U.log("building stadium models first...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
U.wait(1)
guard = guard + 1
end
end
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
OverworldBattle.setting:setValue("stadium", game)
-- party FIRST, at ordinary odds: Pokemon.new is where shininess is
-- decided, so pinning the odds before this made the player's own Pikachu
-- shiny -- and that tints the player's side, which during the intro is the
-- trainer sprite. The foe is what these runs are about.
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
Shiny.setOdds(1) -- from here on: every encounter
-- Five outdoor places, deliberately unalike -- a coast, an open route, a
-- water city, a rocky pass and a wooded shore -- so the recording is five
-- different-looking fights and not the same meadow five times. Each mon is
-- put somewhere its colour has something to sit against.
local RUNS = {
{ "CHARIZARD", 50, "ROUTE_4", 10, 5 },
{ "ELECTRODE", 40, "VIRIDIAN_CITY", 20, 20 },
{ "VAPOREON", 42, "ROUTE_25", 12, 5 },
{ "DRATINI", 30, "ROUTE_3", 10, 5 },
}
-- a beat before the first one, so a recorder that started with the window
-- is not already mid-encounter by the time it is rolling
U.log("starting in 3...")
U.wait(60)
U.log("2...")
U.wait(60)
U.log("1...")
U.wait(60)
for i, r in ipairs(RUNS) do
local species, level, map, cx, cy = r[1], r[2], r[3], r[4], r[5]
if not game.data.pokemon[species] then
U.log(("skip %s -- not in this dataset"):format(species))
elseif not game.data.maps[map] then
U.log(("skip %s -- no map %s"):format(species, map))
else
U.teleport(game, map, cx, cy, "down")
U.wait(SETTLE)
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%d/4 %-10s %-14s shiny=%s")
:format(i, species, map, tostring(Shiny.isShiny(mon))))
-- the arrival, then three seconds of nothing but the Pokemon
U.wait(ARRIVE)
U.wait(HOLD)
-- close the battle and go back to the map
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(BETWEEN)
end
end
Shiny.setOdds(8192)
U.log("done -- five encounters")
end
+182
View File
@@ -0,0 +1,182 @@
-- Driver: ONE shiny encounter, at real 1x speed, held open until you close
-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh.
--
-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- ------- why the pacing is done here
--
-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns
-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the
-- machine will go and the loop takes one logic step (Game:update(1/60)) per
-- turn of it. That is right for a screenshot script and wrong for a capture:
-- the game runs at whatever multiple of real time the hardware manages, so
-- "wait 180 frames" is three seconds only by coincidence.
--
-- The fix does not need an engine change. The loop is blocked while this
-- coroutine is running, so sleeping HERE before each yield paces the whole
-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by
-- construction. Nothing else in the engine has to know.
--
-- ------- and why it never finishes
--
-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So
-- holding the battle open forever is exactly how the window stays up until
-- somebody closes it, which is the handshake this run wants: one window, one
-- Pokemon, closed by hand, and only then the next.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local CX = tonumber(os.getenv("DS_CX") or "") or 5
local CY = tonumber(os.getenv("DS_CY") or "") or 8
local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds
-- ------- real-time pacing
--
-- Each call is one logic step AND one sixtieth of a second of wall clock.
-- The target is carried forward rather than measured from "now" so a slow
-- frame is absorbed by the next one instead of compounding into drift.
local nextAt = nil
local function step()
local now = love.timer.getTime()
nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60)
local slack = nextAt - now
if slack > 0 then
love.timer.sleep(slack)
else
nextAt = now -- fell behind; do not try to catch up
end
coroutine.yield()
end
local function hold(seconds)
for _ = 1, math.floor(seconds * 60) do step() end
end
-- Unpaced, on purpose: a model rebuild is a loading screen, not part of
-- the capture, and there is no reason to watch it at 1x.
local function fast(n)
for _ = 1, n do coroutine.yield() end
end
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
if not StadiumInstall.ready() then
U.log("building stadium models (not part of the capture)...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
fast(1)
guard = guard + 1
end
end
-- STADIUM A stages the fight on the MAP, which wants clear ground. A cave
-- floor often has none, and the mode's answer there is STADIUM B: the two
-- carried discs, which work anywhere. DS_RUNG picks between them per
-- encounter rather than forcing one choice on every location.
OverworldBattle.setting:setValue(os.getenv("DS_RUNG") or "stadium", game)
-- THE PARTY IS BUILT FIRST, at ordinary odds, and the roll is only pinned
-- afterwards. Pokemon.new is where shininess is decided, so setting the
-- odds before this line made the player's own Pikachu shiny too -- and a
-- shiny on the player's side tints that side's pic, which during the intro
-- is the TRAINER, so the player sprite came out discoloured for the whole
-- send-out. The foe is the one this run is about.
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
Shiny.setOdds(1) -- from here on: the encounter
if not game.data.pokemon[SPECIES] then
U.log("no such species: " .. SPECIES)
return
end
if not game.data.maps[MAP] then
U.log("no such map: " .. MAP)
return
end
U.teleport(game, MAP, CX, CY, "down")
hold(1.6) -- let the neighbourhood mesh
hold(LEAD) -- a beat before the fight opens
local battle = BattleState.newWild(game, SPECIES, LEVEL)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- ------- start the shot wider
--
-- BattleCam.zoom is a multiple of the rig's own frame height, so ABOVE one
-- is zoomed OUT -- "the fight in its own landscape", per the note on the
-- constants. 1.15 is fifteen percent wider, which happens to be exactly
-- one notch of the player's own wheel (ZOOM_STEP).
--
-- Set AFTER the battle is pushed, because OverworldBattle's begin path
-- calls BattleCam.reset() and that puts zoom and zoomGoal back to 1. Both
-- are set, not just the goal: leaving the goal alone would make the shot
-- glide outward over the first fifth of a second, and this wants to OPEN
-- wide rather than pull back once the recording has started.
local DS_ZOOM = tonumber(os.getenv("DS_ZOOM") or "") or 1.15
local okCam, BattleCam = pcall(lib.require, "BattleCam")
if okCam and BattleCam then
BattleCam.zoom, BattleCam.zoomGoal = DS_ZOOM, DS_ZOOM
end
local mon = battle.enemy and battle.enemy.mon
U.log(("%s at %s -- shiny=%s zoom=%.2f (1x; close the window when done)")
:format(SPECIES, MAP, tostring(Shiny.isShiny(mon)), DS_ZOOM))
-- The battle stays up, at 1x, until the window is closed by hand. No taps:
-- the recording should be the Pokemon standing there, not a text box being
-- clicked through.
--
-- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set
-- it to a number of seconds and the run should take about that long by the
-- wall clock, which is the only way to prove 1x is actually 1x.
-- DS_SHOTS: capture the ARRIVAL as a strip, for checking that the sparkle
-- fires on its own rather than only when a test arms it by hand. This is
-- the same code path the capture run uses, which is the point -- the last
-- bug here hid precisely because it was verified through a driver that
-- armed the effect itself.
local shots = os.getenv("DS_SHOTS")
if shots then
for k = 1, 26 do
U.shot(game, ("%s/arrive_%02d.png"):format(shots, k))
hold(0.15)
end
local ShinyFx = lib.require("ShinyFx")
local d = ShinyFx.debug or {}
U.log(("fx: armed=%s cleared=%s calls=%s noArena=%s noLive=%s quads=%s")
:format(tostring(d.armed), tostring(d.cleared), tostring(d.calls),
tostring(d.noArena), tostring(d.noLive), tostring(d.quads)))
U.log("arrival strip written to " .. shots)
return
end
local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0
if auto > 0 then
local t0 = love.timer.getTime()
hold(auto)
U.log(("autoclose: asked for %.1fs, took %.2fs")
:format(auto, love.timer.getTime() - t0))
return
end
while true do step() end
end
+643
View File
@@ -0,0 +1,643 @@
-- generated fixture: {dex, srcR,srcG,srcB, wantR,wantG,wantB}
return {
{6,222,131,123,160,165,185},
{6,189,115,41,105,96,134},
{6,16,16,8,12,11,13},
{6,115,57,24,60,58,81},
{6,230,139,57,131,122,165},
{6,213,164,74,139,126,161},
{6,41,32,24,31,30,35},
{6,8,49,57,39,37,26},
{6,82,41,8,39,36,54},
{6,246,172,74,152,138,182},
{6,172,98,32,92,84,120},
{6,65,49,24,43,39,50},
{6,213,131,123,157,161,179},
{6,156,156,148,152,151,153},
{6,255,246,156,209,193,218},
{6,205,164,74,137,123,156},
{6,24,90,90,65,61,49},
{6,172,98,24,88,80,116},
{6,123,24,24,61,68,86},
{6,16,90,82,62,55,44},
{6,230,148,65,137,127,168},
{6,139,74,24,72,67,96},
{6,156,90,41,89,84,113},
{6,123,131,131,128,127,126},
{6,246,164,90,157,148,188},
{6,222,139,57,128,119,160},
{6,98,57,16,52,47,67},
{6,32,131,131,94,87,69},
{6,148,90,32,82,76,105},
{6,197,123,115,146,149,166},
{6,222,106,106,150,157,178},
{6,238,222,106,176,156,188},
{6,32,41,32,38,35,36},
{6,32,90,90,68,64,54},
{6,32,49,49,43,41,38},
{6,82,16,16,41,45,57},
{6,189,189,189,189,189,189},
{6,148,90,41,86,81,108},
{6,8,16,24,17,18,14},
{6,90,98,139,114,121,108},
{42,123,41,115,16,15,26},
{42,90,131,148,26,33,28},
{42,90,156,189,27,42,32},
{42,57,8,57,5,5,11},
{42,123,106,197,30,44,46},
{42,115,148,222,29,55,46},
{42,98,57,98,17,17,22},
{42,49,65,74,14,17,15},
{42,197,189,205,46,50,53},
{42,32,164,230,20,46,27},
{42,41,205,246,19,52,24},
{42,74,131,164,24,35,28},
{42,90,98,189,27,42,40},
{42,41,156,197,20,40,24},
{42,98,90,180,27,40,40},
{42,24,115,164,15,32,20},
{42,164,189,205,39,53,44},
{42,164,106,180,30,33,42},
{42,74,148,213,25,47,34},
{42,82,139,156,25,34,27},
{42,90,180,230,25,55,34},
{42,74,115,139,23,31,25},
{42,49,189,230,21,49,26},
{42,65,82,98,18,22,20},
{42,172,115,189,31,35,45},
{42,49,123,189,21,39,28},
{42,172,197,197,42,50,42},
{42,189,74,164,28,25,41},
{42,65,57,123,18,26,27},
{42,49,16,49,6,6,10},
{42,32,57,74,11,16,12},
{42,65,156,222,23,49,33},
{42,106,98,189,29,43,43},
{42,222,213,222,51,51,58},
{42,172,197,205,41,53,44},
{42,238,238,238,60,60,60},
{42,189,197,205,46,53,49},
{42,41,8,49,5,6,10},
{42,49,0,49,3,3,9},
{42,205,213,213,50,55,50},
{79,139,82,57,28,18,31},
{79,90,74,57,21,16,21},
{79,246,106,74,48,17,63},
{79,222,131,123,46,27,59},
{79,156,131,82,36,24,34},
{79,213,90,90,38,24,52},
{79,222,197,148,62,31,57},
{79,230,205,148,66,29,58},
{79,213,189,156,58,34,56},
{79,197,189,139,51,33,44},
{79,180,106,90,37,26,42},
{79,230,115,115,43,24,62},
{79,148,82,82,29,24,34},
{79,246,156,156,50,24,76},
{79,24,8,8,4,3,5},
{79,131,65,41,25,14,29},
{79,213,98,82,40,23,51},
{79,49,32,8,10,4,10},
{79,123,74,49,25,16,27},
{79,57,32,24,11,8,13},
{79,180,57,65,28,20,39},
{79,164,82,57,32,19,36},
{79,148,106,82,32,24,34},
{79,230,123,106,47,23,61},
{79,238,115,98,47,21,63},
{79,246,115,98,49,19,67},
{79,238,139,131,49,24,68},
{79,139,82,49,29,16,31},
{79,246,131,106,53,20,68},
{79,197,98,74,38,23,45},
{79,213,106,98,41,25,53},
{79,172,98,90,34,26,40},
{79,197,98,82,38,24,46},
{79,123,57,32,23,12,26},
{79,189,189,180,48,44,46},
{79,213,213,213,53,53,53},
{79,115,106,82,27,22,26},
{79,74,32,32,13,10,17},
{79,148,74,57,28,19,33},
{79,238,246,238,72,60,49},
{146,172,131,0,231,152,197},
{146,205,82,0,234,162,229},
{146,57,57,156,183,215,205},
{146,106,24,0,216,132,227},
{146,24,24,32,162,177,172},
{146,74,65,123,183,206,202},
{146,213,172,82,230,199,219},
{146,205,90,16,230,172,226},
{146,246,197,8,235,179,209},
{146,41,24,82,153,205,203},
{146,255,230,115,245,212,229},
{146,246,213,41,237,189,213},
{146,74,90,131,188,208,196},
{146,189,189,189,230,230,230},
{146,115,74,0,227,135,199},
{146,246,123,0,236,175,226},
{146,197,131,8,231,165,210},
{146,180,123,0,232,154,205},
{146,106,115,65,204,182,186},
{146,98,32,0,225,129,226},
{146,255,180,0,237,177,215},
{146,205,156,16,230,172,206},
{146,197,156,74,225,196,215},
{146,74,57,148,182,214,209},
{146,255,123,0,237,177,228},
{146,115,57,0,227,135,212},
{146,246,255,222,253,245,245},
{146,106,65,0,227,132,200},
{146,98,16,0,210,129,226},
{146,230,189,16,232,179,207},
{146,255,230,41,240,190,212},
{146,255,246,32,239,187,207},
{146,213,156,41,227,187,214},
{146,222,123,0,235,167,220},
{146,164,98,0,231,150,209},
{146,148,49,8,223,151,226},
{146,90,65,0,225,127,187},
{146,255,213,16,238,182,211},
{146,24,32,49,156,191,168},
{146,255,180,16,238,182,218},
{96,131,82,57,175,159,190},
{96,32,32,8,173,102,161},
{96,197,156,0,213,138,216},
{96,148,115,41,191,152,197},
{96,57,57,41,160,144,158},
{96,131,115,8,203,121,200},
{96,65,57,16,180,115,180},
{96,148,57,57,167,162,196},
{96,131,32,49,142,142,195},
{96,139,98,32,185,144,197},
{96,123,82,49,175,152,189},
{96,213,213,213,234,234,234},
{96,139,90,65,179,165,192},
{96,131,90,57,178,159,190},
{96,164,148,98,200,185,201},
{96,139,98,8,193,124,205},
{96,106,98,49,183,150,182},
{96,115,90,24,186,132,193},
{96,115,98,32,188,139,190},
{96,57,49,24,166,126,170},
{96,172,131,8,204,135,210},
{96,65,57,49,158,149,163},
{96,106,98,82,178,169,180},
{96,49,49,32,159,136,155},
{96,180,139,8,206,138,211},
{96,131,123,90,189,176,189},
{96,156,148,123,201,193,201},
{96,106,90,8,199,113,198},
{96,148,115,24,195,140,201},
{96,115,115,123,186,188,186},
{96,106,65,57,164,155,181},
{96,255,197,0,219,159,223},
{96,230,222,205,238,233,239},
{96,115,98,57,181,156,185},
{96,180,172,131,211,199,211},
{96,148,123,57,192,162,196},
{96,230,180,0,216,150,220},
{96,156,115,0,202,122,211},
{96,172,123,32,195,152,205},
{96,123,82,57,174,157,188},
{140,49,49,49,101,101,101},
{140,255,8,131,174,155,151},
{140,139,82,32,135,138,118},
{140,123,0,8,124,113,96},
{140,230,24,24,169,162,149},
{140,32,24,8,81,85,73},
{140,230,156,156,212,210,205},
{140,82,0,0,106,98,83},
{140,131,82,24,128,132,111},
{140,74,0,0,103,95,80},
{140,222,172,172,214,212,209},
{140,82,74,82,123,121,122},
{140,197,106,106,182,179,173},
{140,246,49,74,184,175,165},
{140,255,16,123,177,159,154},
{140,156,24,57,143,129,120},
{140,156,82,8,135,139,111},
{140,255,131,156,214,208,203},
{140,222,213,205,224,225,223},
{140,82,49,16,106,109,92},
{140,205,172,172,207,206,204},
{140,230,172,172,217,215,212},
{140,230,82,115,188,180,174},
{140,255,82,189,198,183,182},
{140,222,156,90,185,187,175},
{140,180,98,16,145,150,125},
{140,238,115,123,202,197,190},
{140,65,8,16,100,92,82},
{140,172,74,82,161,157,151},
{140,255,172,197,228,223,220},
{140,197,57,65,166,160,152},
{140,205,131,49,164,166,152},
{140,57,57,57,106,106,106},
{140,131,32,49,134,125,115},
{140,57,32,32,101,98,94},
{140,123,8,41,125,110,100},
{140,246,32,82,178,167,158},
{140,164,98,32,144,147,127},
{140,255,0,90,171,155,147},
{140,255,106,172,206,195,192},
{23,139,106,16,46,41,75},
{23,172,131,24,58,53,94},
{23,106,8,24,29,50,57},
{23,246,197,49,85,73,149},
{23,255,213,41,88,69,153},
{23,238,180,16,74,64,126},
{23,222,123,131,101,139,158},
{23,246,213,82,99,81,165},
{23,156,106,172,93,115,97},
{23,90,16,24,29,44,50},
{23,164,90,98,85,99,106},
{23,213,164,41,77,71,119},
{23,115,74,98,65,77,75},
{23,230,189,106,98,92,160},
{23,115,65,123,62,79,67},
{23,222,172,74,87,82,140},
{23,213,172,90,93,88,139},
{23,255,213,57,91,73,161},
{23,156,115,164,96,113,101},
{23,222,156,8,63,56,116},
{23,123,82,123,71,83,76},
{23,98,49,115,52,71,55},
{23,106,74,65,58,63,70},
{23,180,180,180,135,135,135},
{23,115,49,90,52,71,67},
{23,98,57,90,52,64,59},
{23,180,123,180,102,125,112},
{23,90,65,41,42,44,56},
{23,238,172,24,73,66,130},
{23,222,164,8,65,56,116},
{23,131,106,41,55,52,77},
{23,180,139,164,110,129,126},
{23,32,8,24,12,18,17},
{23,213,164,24,71,62,115},
{23,131,74,148,73,94,77},
{23,65,0,16,15,30,34},
{23,139,106,49,59,58,83},
{23,189,139,24,62,57,103},
{23,131,65,74,64,78,83},
{23,65,49,0,18,15,34},
{114,205,197,197,197,197,205},
{114,180,180,189,180,189,180},
{114,123,123,230,123,230,123},
{114,205,205,255,205,255,205},
{114,189,49,74,49,74,189},
{114,222,213,222,213,222,222},
{114,82,82,205,82,205,82},
{114,131,131,238,131,238,131},
{114,82,82,164,82,164,82},
{114,230,230,255,230,255,230},
{114,41,41,32,41,32,41},
{114,148,32,57,32,57,148},
{114,65,65,90,65,90,65},
{114,172,172,180,172,180,172},
{114,123,123,213,123,213,123},
{114,238,230,238,230,238,238},
{114,41,41,49,41,49,41},
{114,238,238,238,238,238,238},
{114,205,197,205,197,205,205},
{114,230,230,222,230,222,230},
{114,90,90,90,90,90,90},
{114,255,255,238,255,238,255},
{114,106,106,238,106,238,106},
{114,246,106,131,106,131,246},
{114,49,49,123,49,123,49},
{114,16,16,41,16,41,16},
{114,255,255,246,255,246,255},
{114,8,8,16,8,16,8},
{114,205,82,106,82,106,205},
{114,205,205,213,205,213,205},
{114,32,32,24,32,24,32},
{114,41,49,106,49,106,41},
{114,41,41,98,41,98,41},
{114,123,123,148,123,148,123},
{114,156,148,205,148,205,156},
{114,156,41,57,41,57,156},
{114,222,90,106,90,106,222},
{114,57,57,65,57,65,57},
{114,123,123,205,123,205,123},
{114,82,74,82,74,82,82},
{134,197,205,213,212,197,213},
{134,205,222,230,224,205,230},
{134,213,222,238,238,213,236},
{134,32,90,90,58,32,90},
{134,164,189,189,175,164,189},
{134,65,90,131,131,65,126},
{134,32,139,172,128,32,172},
{134,156,172,189,188,156,189},
{134,255,246,148,148,255,187},
{134,0,8,16,15,0,16},
{134,24,41,82,82,24,73},
{134,57,148,180,144,57,180},
{134,123,197,197,156,123,197},
{134,148,197,205,182,148,205},
{134,24,8,0,5,24,0},
{134,65,90,148,148,65,136},
{134,164,213,222,199,164,222},
{134,41,74,123,123,41,119},
{134,8,16,65,65,8,47},
{134,213,222,156,156,222,195},
{134,74,57,32,32,74,34},
{134,65,106,148,144,65,148},
{134,24,41,98,98,24,82},
{134,115,123,123,119,115,123},
{134,49,90,98,79,49,98},
{134,131,172,197,186,131,197},
{134,0,24,16,3,0,24},
{134,32,82,98,78,32,98},
{134,156,180,205,203,156,205},
{134,148,213,222,190,148,222},
{134,139,197,205,177,139,205},
{134,32,49,98,98,32,85},
{134,82,98,148,148,82,134},
{134,41,49,115,115,41,90},
{134,222,230,230,226,222,230},
{134,106,106,115,115,106,111},
{134,32,57,106,106,32,98},
{134,32,106,148,126,32,148},
{134,32,90,131,118,32,131},
{134,41,90,139,134,41,139},
{143,82,106,90,105,123,120},
{143,148,115,98,154,149,125},
{143,230,222,213,230,230,221},
{143,139,65,65,149,121,94},
{143,16,0,8,63,15,15},
{143,213,180,123,197,203,154},
{143,189,90,90,181,154,127},
{143,115,57,41,130,113,70},
{143,57,57,57,82,82,82},
{143,172,139,90,165,169,124},
{143,189,156,123,186,186,150},
{143,222,222,222,226,226,226},
{143,255,230,197,243,245,214},
{143,230,230,230,233,233,233},
{143,197,197,180,197,201,192},
{143,222,205,123,192,210,156},
{143,238,230,180,219,231,199},
{143,90,90,65,100,110,90},
{143,213,197,123,188,203,154},
{143,205,197,123,180,198,153},
{143,90,82,57,103,110,83},
{143,205,180,139,196,200,164},
{143,213,213,189,208,214,201},
{143,115,65,65,130,111,91},
{143,164,131,74,157,164,108},
{143,213,197,156,202,209,178},
{143,123,106,65,127,136,92},
{143,180,148,148,184,175,167},
{143,205,197,156,194,203,176},
{143,123,82,74,136,124,100},
{143,24,8,8,60,46,32},
{143,255,238,189,235,244,208},
{143,230,222,205,227,229,215},
{143,213,180,131,200,205,160},
{143,131,65,65,142,118,93},
{143,222,189,139,208,213,167},
{143,24,16,8,60,60,32},
{143,205,189,156,199,203,176},
{143,156,148,98,147,159,127},
{143,164,131,98,165,165,128},
{95,57,49,65,34,41,31},
{95,0,197,230,144,42,0},
{95,197,197,238,181,201,71},
{95,0,16,65,41,37,0},
{95,148,156,156,99,92,91},
{95,238,238,230,132,126,167},
{95,98,98,98,61,61,61},
{95,148,148,156,98,99,91},
{95,16,32,156,94,98,10},
{95,0,49,106,66,46,0},
{95,0,123,197,123,65,0},
{95,0,180,238,149,59,0},
{95,0,8,16,10,7,0},
{95,139,148,164,106,101,83},
{95,65,74,82,51,47,41},
{95,189,197,197,128,115,113},
{95,0,90,197,123,85,0},
{95,106,115,131,82,79,66},
{95,0,139,205,128,60,0},
{95,131,123,148,85,94,76},
{95,0,24,49,31,20,0},
{95,0,8,98,57,61,0},
{95,98,98,115,70,72,61},
{95,139,139,139,87,87,87},
{95,49,49,57,35,36,31},
{95,0,74,131,82,48,0},
{95,115,106,131,74,82,66},
{95,0,115,197,123,70,0},
{95,156,148,180,106,121,84},
{95,123,123,139,86,87,77},
{95,0,41,156,98,86,0},
{95,0,106,172,108,57,0},
{95,16,57,156,98,85,10},
{95,0,115,148,93,35,0},
{95,205,205,230,164,176,96},
{95,131,139,156,100,96,80},
{95,189,197,205,140,128,106},
{95,98,106,131,82,80,61},
{95,213,213,246,194,216,71},
{95,24,32,32,20,16,15},
{35,180,74,65,180,65,123},
{35,197,82,82,197,82,140},
{35,172,131,131,172,131,152},
{35,65,41,41,65,41,53},
{35,213,230,230,213,230,230},
{35,172,74,65,172,65,119},
{35,148,164,164,148,164,164},
{35,148,106,98,148,98,123},
{35,90,65,57,90,57,74},
{35,57,57,156,57,57,156},
{35,222,164,164,222,164,193},
{35,255,222,222,255,222,238},
{35,8,8,0,1,8,0},
{35,148,148,156,148,148,156},
{35,49,41,16,15,52,10},
{35,222,246,238,222,246,238},
{35,148,82,90,148,82,115},
{35,57,8,8,57,8,33},
{35,131,131,131,131,131,131},
{35,255,189,197,255,189,222},
{35,205,90,82,205,82,144},
{35,230,197,197,230,197,214},
{35,238,139,139,238,139,189},
{35,180,115,115,180,115,148},
{35,65,57,57,65,57,57},
{35,172,139,131,172,131,152},
{35,131,32,24,131,24,78},
{35,65,49,8,9,69,0},
{35,41,24,8,8,44,2},
{35,180,139,123,180,123,152},
{35,98,123,123,98,123,123},
{35,41,41,115,41,41,115},
{35,106,16,16,106,16,61},
{35,106,131,131,106,131,131},
{35,16,16,24,16,16,24},
{35,156,139,139,156,139,139},
{35,123,32,24,123,24,74},
{35,49,24,24,49,24,37},
{35,16,16,41,16,16,41},
{35,189,164,172,189,164,176},
{36,230,148,172,230,148,189},
{36,255,148,164,255,148,202},
{36,156,123,123,156,123,123},
{36,189,156,148,189,148,169},
{36,98,41,24,98,24,61},
{36,74,0,0,74,0,37},
{36,148,82,74,148,74,111},
{36,65,49,49,65,49,49},
{36,238,197,180,238,180,209},
{36,230,180,172,230,172,201},
{36,24,16,16,24,16,20},
{36,246,148,164,246,148,197},
{36,180,156,156,180,156,156},
{36,49,49,49,49,49,49},
{36,222,189,189,222,189,206},
{36,98,90,90,98,90,90},
{36,148,131,123,148,131,123},
{36,222,222,222,222,222,222},
{36,222,189,180,222,180,201},
{36,213,156,156,213,156,185},
{36,222,180,180,222,180,201},
{36,197,123,115,197,115,156},
{36,230,139,156,230,139,185},
{36,115,90,90,115,90,90},
{36,32,8,8,32,8,20},
{36,65,49,41,65,41,53},
{36,8,0,0,8,0,4},
{36,98,32,32,98,32,65},
{36,164,98,98,164,98,131},
{36,106,98,90,106,98,90},
{36,230,205,197,230,197,214},
{36,246,213,205,246,205,226},
{36,164,148,148,164,148,148},
{36,238,164,189,238,164,201},
{36,156,131,123,156,131,123},
{36,230,172,180,230,172,201},
{36,24,8,8,24,8,16},
{36,106,90,90,106,90,90},
{36,213,197,189,213,189,201},
{36,246,172,156,246,156,201},
{39,255,189,197,250,194,250},
{39,222,255,255,240,255,222},
{39,139,131,139,139,131,139},
{39,74,148,139,115,154,68},
{39,222,246,246,235,248,220},
{39,131,156,148,131,156,148},
{39,41,82,82,64,85,38},
{39,189,90,98,182,97,182},
{39,180,139,148,177,142,177},
{39,205,123,131,199,129,199},
{39,180,148,156,178,150,178},
{39,74,164,148,124,171,67},
{39,230,230,238,234,239,229},
{39,106,197,189,157,204,99},
{39,74,90,90,74,90,90},
{39,230,139,148,223,146,223},
{39,41,98,82,73,102,37},
{39,246,156,164,239,163,239},
{39,180,197,197,180,197,197},
{39,139,90,106,135,94,135},
{39,32,74,74,55,77,29},
{39,131,82,90,127,86,127},
{39,32,106,90,73,112,26},
{39,164,189,189,178,191,162},
{39,41,115,98,82,121,35},
{39,246,172,172,240,178,240},
{39,131,205,197,172,211,125},
{39,197,106,106,190,113,190},
{39,98,65,74,96,67,96},
{39,205,246,246,228,249,202},
{39,98,172,156,139,178,92},
{39,106,180,172,147,186,100},
{39,24,57,57,42,59,22},
{39,172,197,197,186,199,170},
{39,115,139,148,115,139,148},
{39,32,106,98,73,112,26},
{39,230,180,197,226,184,226},
{39,57,90,82,75,92,55},
{39,74,148,131,115,154,68},
{39,189,238,238,216,242,185},
{40,164,123,131,161,126,161},
{40,139,131,131,139,131,131},
{40,156,139,139,156,139,139},
{40,222,164,180,218,168,218},
{40,222,205,205,221,206,221},
{40,255,238,246,254,239,254},
{40,230,164,164,225,169,225},
{40,131,131,131,131,131,131},
{40,197,115,123,191,121,191},
{40,82,106,106,82,106,106},
{40,230,172,189,226,176,226},
{40,148,156,164,148,156,164},
{40,74,90,90,74,90,90},
{40,82,156,139,123,162,76},
{40,164,115,131,160,119,160},
{40,82,49,65,80,51,80},
{40,65,106,106,88,109,62},
{40,74,98,98,74,98,98},
{40,189,106,106,183,112,183},
{40,222,246,246,235,248,220},
{40,222,197,205,220,199,220},
{40,115,139,148,115,139,148},
{40,238,246,246,242,247,237},
{40,74,131,123,106,135,70},
{40,156,115,131,153,118,153},
{40,82,115,115,100,117,80},
{40,230,148,148,224,154,224},
{40,148,213,205,184,218,143},
{40,246,189,197,242,193,242},
{40,139,156,164,139,156,164},
{40,172,189,197,186,199,170},
{40,230,172,180,226,176,226},
{40,164,131,139,162,133,162},
{40,205,148,156,201,152,201},
{40,238,246,238,238,246,238},
{40,156,115,123,153,118,153},
{40,156,106,123,152,110,152},
{40,197,131,131,192,136,192},
{40,255,164,164,248,171,248},
{40,131,172,164,154,175,128},
{130,123,197,230,220,80,70},
{130,230,222,180,230,222,180},
{130,255,222,131,255,222,131},
{130,57,131,180,148,53,47},
{130,106,82,41,106,82,41},
{130,98,180,238,232,56,43},
{130,65,90,98,80,55,53},
{130,189,172,123,189,172,123},
{130,65,123,148,121,58,53},
{130,221,221,221,221,221,221},
{130,57,82,106,87,49,47},
{130,222,222,222,222,222,222},
{130,189,164,106,189,164,106},
{130,85,85,85,85,85,85},
{130,172,148,98,172,148,98},
{130,115,189,230,221,73,62},
{130,189,164,74,189,164,74},
{130,230,246,246,223,171,167},
{130,205,180,90,205,180,90},
{130,82,57,16,82,57,16},
{130,238,90,106,238,90,106},
{130,156,180,164,156,180,164},
{130,82,98,106,82,98,106},
{130,164,230,246,239,106,97},
{130,133,133,133,133,133,133},
{130,156,131,57,156,131,57},
{130,90,49,106,87,43,40},
{130,98,115,98,98,115,98},
{130,98,98,65,98,98,65},
{130,148,49,65,148,49,65},
{130,156,156,156,156,156,156},
{130,49,49,82,67,42,40},
{130,230,222,230,230,222,230},
{130,153,153,153,153,153,153},
{130,255,230,255,255,150,143},
{130,8,82,131,107,13,7},
{130,139,213,255,255,81,68},
{130,32,16,32,26,14,13},
{130,164,57,65,164,57,65},
{130,82,82,32,82,82,32},
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Shiny encounters, one per outdoor place, one window at a time.
#
# bash mods/DramaticShapeVoxelMod/tests/shiny_run.sh
#
# Run from the PROJECT ROOT.
#
# Each Pokemon gets its OWN game window, launched only after the previous one
# has been closed -- the loop blocks on the process, so closing the window is
# what advances it. Nothing is on a timer, so a take can be as long as it
# needs to be, and a bad one can just be closed and re-run.
#
# The battles run at true 1x: tests/shiny_one.lua paces itself against the
# wall clock, because a POKEPORT_DRIVER run is otherwise unpaced and goes as
# fast as the machine can manage.
set -u
LOVE=${LOVE:-/c/Program Files/LOVE/lovec.exe}
DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua
# species | level | map | cell x | cell y | rung
#
# Lapras out on the open sea: ROUTE_20 is the 50-wide water run between
# Fuchsia and Cinnabar, and the middle of it is nothing but water in every
# direction. A species or map this dataset does not have is skipped with a
# line rather than failing the run.
#
# The rung column picks STADIUM A (the fight staged on the map itself) or
# STADIUM B (the two carried discs). Over open water there is no clear GROUND
# to stage on, which is exactly the case B exists for -- see shiny_one.lua.
RUNS=(
"LAPRAS|40|ROUTE_20|25|4|stadium"
)
OPTS="$APPDATA/LOVE/pokemon-love2d/options.lua"
if [ -f "$OPTS" ]; then
cp "$OPTS" "$OPTS.shiny_run_backup"
echo "backed up options.lua"
fi
i=0
for row in "${RUNS[@]}"; do
i=$((i + 1))
IFS='|' read -r SPECIES LEVEL MAP CX CY RUNG <<< "$row"
echo ""
echo "=== $i/${#RUNS[@]} $SPECIES at $MAP ==="
echo " close the window when you are done recording it"
DS_SPECIES="$SPECIES" DS_LEVEL="$LEVEL" DS_MAP="$MAP" \
DS_CX="$CX" DS_CY="$CY" DS_RUNG="${RUNG:-stadium}" \
POKEPORT_DRIVER="$DRIVER" "$LOVE" .
done
# The game persists the whole options table mid-run (OverworldBattle.forceOG),
# so a run leaves the display settings it used on disk. Put them back.
if [ -f "$OPTS.shiny_run_backup" ]; then
cp "$OPTS.shiny_run_backup" "$OPTS"
echo ""
echo "options.lua restored"
fi
echo "done -- ${#RUNS[@]} encounters"
+251
View File
@@ -0,0 +1,251 @@
-- Driver: photograph the shiny system, each case beside its own control.
--
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/shiny_update \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_shots.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- SHOT_DIR must already exist -- the capture writes with io.open.
--
-- EVERY CASE IS SHOT TWICE, the same species on the same tile, once
-- ordinary and once shiny. A single shiny screenshot proves nothing: these
-- are N64 models under a day/night tint on generated ground, and "that
-- looks a bit purple" is not evidence. The pair is.
--
-- The four species are chosen to exercise the four things that can break:
--
-- GYARADOS one of the five Stadium gives a REAL alternate texture, so
-- it runs the explicit colour table rather than the HSL slide.
-- Blue to red, the least deniable shiny in Gen 1.
-- CHARIZARD the biggest slide in the set (H -136, S -6). Also the one
-- whose canonical shiny is famously black, which the Stadium
-- values do NOT reproduce -- they give a dusky slate-violet.
-- Shot precisely so that difference is on the record.
-- GOLBAT L -6, the darkest lightness step there is. If the guard
-- rails are wrong this is where it goes to mud.
-- PONYTA made of fire. Its flames are GENERATED frames, excluded
-- from the recolour, so the correct result is a recoloured
-- body with an ordinary mane. That exclusion is invisible in
-- every other species.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/shiny_update"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- nothing to shoot")
return
end
local Shiny = lib.require("Shiny")
local ShinyFx = lib.require("ShinyFx")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
local StadiumPack = lib.require("StadiumPack")
-- ------- the models
--
-- REV went to 3 with the shiny variants, so an existing cache is stale and
-- the game rebuilds all 151 on the loading screen. That is a minute or so
-- of frames, and it has to be waited out rather than assumed: shooting
-- before it lands gets flat 2D pics and a very confusing set of images.
-- Driven from HERE rather than waited on. The build is normally pumped by
-- the loading screen StadiumScreen.maybePush puts up on the first world
-- frame, but a driver owns the frame loop and that screen never came up --
-- a first run sat at "idle 0/151" for twelve thousand frames and shot four
-- unrecoloured Pokemon. Calling begin/step directly is both faster and
-- honest about what is being tested, which is the extraction, not the
-- screen that usually triggers it.
-- the upgrade question, asked before anything is built: with a stale
-- marker on disk, does this machine know it has work to do?
U.log(("upgrade check: ready=%s usable=%s available=%s pending=%s rom=%s")
:format(tostring(StadiumInstall.ready()),
tostring(StadiumInstall.usable()),
tostring(StadiumInstall.available()),
tostring(StadiumInstall.pending()),
tostring(StadiumInstall.romPresent())))
if not StadiumInstall.ready() then
local ok, err = StadiumInstall.begin()
U.log(("stadium build: begin=%s %s"):format(tostring(ok), tostring(err or "")))
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
-- several species per frame: 151 of them at one a frame is a long
-- wait for no reason, and nothing here needs to be drawn
for _ = 1, 6 do StadiumInstall.step() end
U.wait(1)
guard = guard + 1
local st = StadiumInstall.status
if st and st.state == "failed" then
U.log("stadium build FAILED: " .. tostring(st.error))
break
end
if guard % 10 == 0 then
U.log((" building: %s %d/%d"):format(tostring(st and st.state),
(st and st.done) or 0, (st and st.total) or 0))
end
end
end
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
-- and prove the shiny packs are actually THERE before shooting anything
for _, dex in ipairs({ 130, 6, 42, 77 }) do
U.log((" pack %03d: normal=%s shiny=%s"):format(
dex, tostring(StadiumPack.available(dex, false)),
tostring(StadiumPack.available(dex, true))))
end
-- STADIUM A: models, staged on the map
OverworldBattle.setting:setValue("stadium", game)
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()))
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
local CASES = {
{ "GYARADOS", 40, "ROUTE_1", 5, 8 },
{ "CHARIZARD", 50, "ROUTE_1", 5, 8 },
{ "GOLBAT", 40, "ROUTE_1", 5, 8 },
{ "PONYTA", 40, "ROUTE_1", 5, 8 },
}
-- Odds are the real lever, so the shiny half goes through the SAME path a
-- player's encounter does -- decided inside Pokemon.new by a roll -- rather
-- than being stamped on afterwards. 1 means every mon; a huge denominator
-- means none, which is what makes the control a control.
local function setOdds(shiny)
Shiny.setOdds(shiny and 1 or 100000000)
end
local function toMenu()
for _ = 1, 16 do U.tap(game, "a") U.wait(8) end
end
local function leave()
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
for i, c in ipairs(CASES) do
local species, level, map, cx, cy = c[1], c[2], c[3], c[4], c[5]
if not game.data.pokemon[species] then
U.log("no such species in this dataset: " .. species)
else
for _, variant in ipairs({ "normal", "shiny" }) do
local shiny = (variant == "shiny")
setOdds(shiny)
U.teleport(game, map, cx, cy, "down")
U.wait(90) -- let the neighbourhood's meshes land
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%s %s: isShiny=%s dvs=%s/%s/%s/%s flag=%s"):format(
species, variant, tostring(Shiny.isShiny(mon)),
tostring(mon and mon.dvs and mon.dvs.attack),
tostring(mon and mon.dvs and mon.dvs.defense),
tostring(mon and mon.dvs and mon.dvs.speed),
tostring(mon and mon.dvs and mon.dvs.special),
tostring(mon and mon.shiny)))
-- THE ARRIVAL. Shot during the send-out rather than after it,
-- because the sparkle is three quarters of a second long and the
-- menu is well past it. Three frames close together, so one of them
-- lands mid-burst whatever the intro's pacing does on this map.
U.wait(70)
if shiny then
for k = 1, 3 do
U.shot(game, ("%s/%d_%s_arrival_%d.png")
:format(DIR, i, species:lower(), k))
U.wait(10)
end
end
toMenu()
U.shot(game, ("%s/%d_%s_%s.png")
:format(DIR, i, species:lower(), variant))
-- and the sparkle again, armed deliberately and shot on the next
-- frame. The arrival shots above catch it in its real moment but
-- depend on intro timing; this one is the effect itself, on the
-- record, at a known point in its life.
if shiny then
for k, v in pairs(ShinyFx.debug) do ShinyFx.debug[k] = 0 end
ShinyFx.arm("enemy")
U.wait(4)
U.shot(game, ("%s/%d_%s_sparkle.png"):format(DIR, i, species:lower()))
local d = ShinyFx.debug
U.log((" fx: calls=%d noArena=%d noImage=%d noMesh=%d noLive=%d quads=%d")
:format(d.calls, d.noArena, d.noImage, d.noMesh, d.noLive,
d.quads))
end
leave()
end
end
end
-- ------- the FLAT art
--
-- Everything above is the STADIUM rung, which draws recoloured 3D models
-- and never touches a pic. The tint is the other half of the feature and
-- needs its own rung to be visible at all: 2D-3D stands the game's own
-- battle pics up as cards, which is the path ShinyUI tints.
OverworldBattle.setting:setValue(true, game)
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()) .. " (cards)")
for _, variant in ipairs({ "normal", "shiny" }) do
setOdds(variant == "shiny")
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(90)
local battle = BattleState.newWild(game, "GYARADOS", 40)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.log(("cards %s: isShiny=%s"):format(
variant, tostring(Shiny.isShiny(battle.enemy and battle.enemy.mon))))
U.wait(70)
toMenu()
U.shot(game, ("%s/6_cards_gyarados_%s.png"):format(DIR, variant))
leave()
end
-- ------- the status page
--
-- A shiny and an ordinary mon of the SAME species, so the star is the only
-- difference between the two images.
local SummaryMenu = require("src.ui.SummaryMenu")
for _, variant in ipairs({ "normal", "shiny" }) do
setOdds(variant == "shiny")
local mon = Pokemon.new(game.data, "GYARADOS", 40)
U.log(("summary %s: isShiny=%s flag=%s"):format(
variant, tostring(Shiny.isShiny(mon)), tostring(mon.shiny)))
game.save.party = { mon }
game.stack:push(SummaryMenu.new(game, mon))
U.wait(20)
U.shot(game, ("%s/5_status_%s.png"):format(DIR, variant))
leave()
end
-- ------- the odds actually being odds
--
-- Not a screenshot, but it belongs in the same run: the rate is the thing
-- a player experiences, and it is the one claim a picture cannot make.
Shiny.setOdds(8192)
local n, hits = 4000, 0
for _ = 1, n do
if Shiny.isShiny(Pokemon.new(game.data, "RATTATA", 5)) then
hits = hits + 1
end
end
U.log(("odds check: %d shinies in %d at 1/8192 (expect ~0-2)")
:format(hits, n))
Shiny.setOdds(8192)
U.log("done -- " .. DIR)
end
+331
View File
@@ -0,0 +1,331 @@
-- The shiny system, headless.
--
-- luajit mods/DramaticShapeVoxelMod/tests/shiny_test.lua [--mod=DIR]
--
-- Run from the PROJECT ROOT (it requires src.pokemon.Stats, the engine's own
-- shiny predicate -- the point being that we agree with the engine rather
-- than carry a second copy of the rule).
--
-- Two halves:
--
-- the DV model that a decided mon really does satisfy the Gen 2
-- pattern, that a miss really does not, that the odds
-- are the number they claim, and that the derived HP DV
-- is kept legal.
-- the recolour against a fixture of real (normal, shiny) colour
-- pairs lifted from the verified texture set, so the
-- Lua transform is checked against the Python that
-- produced the shipped colours rather than against
-- itself.
package.path = "./?.lua;./?/init.lua;" .. package.path
local args = {}
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
end
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
-- ------- the mod namespace, enough of it
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
loaded[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(V)
end
return loaded[name]
end
function V.data(name)
return assert(loadfile(MOD .. "/data/" .. name .. ".lua"))(V)
end
V.mod = { log = { warn = function() end, info = function() end } }
local Shiny = V.require("Shiny")
local ShinyPalette = V.require("ShinyPalette")
local Stats = require("src.pokemon.Stats")
-- ------- a tiny harness
local pass, fail = 0, 0
local function ok(cond, what)
if cond then
pass = pass + 1
else
fail = fail + 1
io.write("FAIL: " .. what .. "\n")
end
end
local function eq(got, want, what)
ok(got == want, ("%s (got %s, want %s)")
:format(what, tostring(got), tostring(want)))
end
-- always-hit and always-miss stand-ins for the odds roll
local function hit() return 1 end
local function miss() return 2 end
local function mon(dvs, level, species)
return { species = species, level = level, dvs = dvs,
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } }
end
-- ------- the DV model
do
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
Shiny.decide(m, hit)
ok(Shiny.isShiny(m), "a hit produces a mon the ENGINE calls shiny")
ok(Stats.isShiny(m.dvs), "and the engine's own predicate agrees")
eq(m.dvs.defense, 10, "defense pinned to 10")
eq(m.dvs.speed, 10, "speed pinned to 10")
eq(m.dvs.special, 10, "special pinned to 10")
eq(m.shiny, true, "the cached flag is set")
end
do
-- nearest legal Attack, so a shiny encounter is not also a stat reroll
local cases = { [0] = 2, [4] = 3, [5] = 6, [9] = 10, [13] = 14, [15] = 15 }
for from, want in pairs(cases) do
local m = mon({ attack = from, defense = 0, speed = 0, special = 0, hp = 0 })
Shiny.decide(m, hit)
eq(m.dvs.attack, want, ("attack %d moves to the nearest legal %d")
:format(from, want))
end
end
do
-- the HP DV is derived from the low bits of the other four; a write that
-- forgets to resync it produces a mon no real game could make
for atk = 0, 15 do
local m = mon({ attack = atk, defense = 3, speed = 7, special = 1, hp = 0 })
Shiny.decide(m, hit)
local want = (m.dvs.attack % 2) * 8 + (m.dvs.defense % 2) * 4
+ (m.dvs.speed % 2) * 2 + (m.dvs.special % 2)
eq(m.dvs.hp, want, "HP DV stays derived from the other four")
end
end
do
-- a MISS must not leave an accidentally-shiny mon shiny, or the rate is
-- the requested one and 1/8192 in parallel
local m = mon({ attack = 2, defense = 10, speed = 10, special = 10, hp = 8 })
ok(Stats.isShiny(m.dvs), "fixture starts out shiny by luck")
Shiny.decide(m, miss)
ok(not Shiny.isShiny(m), "a miss clears an accidentally-shiny mon")
eq(m.shiny, nil, "and clears the cached flag rather than storing false")
end
do
-- the odds are the number they claim. 1/1 must be every time; a large
-- denominator must essentially never fire on a fixed stub.
local saved = Shiny.ODDS_DENOM
Shiny.setOdds(1)
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
Shiny.decide(m, function(_, hi) return math.random(1, hi) end)
ok(Shiny.isShiny(m), "odds of 1 make every mon shiny")
Shiny.setOdds(0)
eq(Shiny.ODDS_DENOM, 1, "a denominator below 1 is refused")
Shiny.setOdds(saved)
eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted")
end
do
-- ------- the row on the menu
--
-- The ladder's first rung is both the default and the fallback, so the
-- canonical 1:8192 has to be it: a player who never opens the menu, and a
-- corrupted options.lua, must both land on the games' own rate.
local s = Shiny.setting
eq(s.values[1], 8192, "the ladder starts at the canonical rate")
eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows")
eq(s.labels[#s.labels], "1:1", "the last rung is every encounter")
eq(#s.values, #s.labels, "every rung has a label")
for i = 2, #s.values do
eq(s.values[i] * 2, s.values[i - 1],
("rung %d is twice as often as the one above"):format(i))
end
-- and the roll READS it. Pulled rather than pushed, so a value written by
-- the mod manager's page -- which notifies nobody -- is seen too.
local before = s:read()
s:setValue(512)
Shiny.unpinOdds()
eq(Shiny.odds(), 512, "the roll follows the row without being told")
eq(Shiny.ODDS_DENOM, 512, "and the live field is written through")
s:setIndex(before)
Shiny.setOdds(8192) -- and pinned again, for the blocks below
end
do
-- stats must follow the DVs, and a full-health mon must stay full: a wild
-- mon that appears at less than full HP is visible in the first frame
local Data = require("src.core.Data")
local haveData = type(Data) == "table" and type(Data.pokemon) == "table"
and Data.pokemon.PIKACHU ~= nil
if haveData then
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 },
10, "PIKACHU")
m.stats = Stats.calc(Data.pokemon.PIKACHU, 10, m.dvs, m.statExp)
m.hp = m.stats.hp
local before = m.stats.hp
Shiny.decide(m, hit)
ok(m.stats.hp >= before, "max HP tracks the raised DVs")
eq(m.hp, m.stats.hp, "a full-health mon stays full after a restat")
else
io.write("note: no ROM-backed data; skipped the restat check\n")
end
end
-- ------- the recolour
do
eq(type(ShinyPalette.forDex(6)), "table", "colours load for Charizard")
local ch = ShinyPalette.forDex(6)
eq(ch.slide.h, -136, "Charizard's hue slide is the Stadium value")
eq(ch.slide.s, -6, "and its saturation step")
ok(ShinyPalette.forDex(130).lut ~= nil,
"Gyarados carries an explicit table, not a slide")
ok(ShinyPalette.forDex(6).lut == nil, "and Charizard does not")
end
do
-- alpha is never touched, and a texture with nothing to do comes back
-- unchanged rather than rebuilt
local fn = ShinyPalette.transform(ShinyPalette.forDex(6))
local px = string.char(222, 131, 123, 77) .. string.char(0, 0, 0, 0)
local out = ShinyPalette.recolorTexels(px, fn)
eq(#out, #px, "the texel string keeps its length")
eq(out:byte(4), 77, "alpha survives the transform")
eq(out:byte(8), 0, "and so does a fully transparent texel's alpha")
end
do
local fixture = loadfile(MOD .. "/tests/shiny_palette_fixture.lua")
if not fixture then
io.write("note: no colour fixture; skipped the cross-check\n")
else
local rows = fixture()
local worst, bad = 0, 0
for _, r in ipairs(rows) do
local fn = ShinyPalette.transform(ShinyPalette.forDex(r[1]))
local gr, gg, gb = r[2], r[3], r[4]
if fn then gr, gg, gb = fn(r[2], r[3], r[4]) end
local d = math.max(math.abs(gr - r[5]), math.abs(gg - r[6]),
math.abs(gb - r[7]))
if d > worst then worst = d end
if d > 2 then bad = bad + 1 end
end
eq(bad, 0, ("%d colour pairs reproduce the shipped textures"):format(#rows))
-- a unit or two of drift is the two languages' float rounding; more than
-- that means the colour model itself has diverged
ok(worst <= 2, ("worst channel drift is %d, within rounding"):format(worst))
end
end
-- ------- the read side
--
-- The extraction writes NNNs.dsm beside NNN.dsm; this is the other half of
-- that contract. Driven off a directory of real packs when one is given
-- (--packs=DIR, e.g. the extract test's --out), because the interesting
-- cases are a shiny pack that EXISTS and one that does not, and both have
-- to be real files for the fallback to be worth testing.
if args.packs then
local Vp = { mod = { log = { warn = function() end, info = function() end } } }
local loadedP = {}
function Vp.require(name)
if loadedP[name] == nil then
loadedP[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(Vp)
end
return loadedP[name]
end
-- stand in for the mod's own reader, pointed at the pack directory
function Vp.mod:read(rel)
local name = rel:match("([^/]+)$")
local fp = io.open(args.packs .. "/" .. name, "rb")
if not fp then return nil end
local b = fp:read("*a")
fp:close()
return b
end
local StadiumPack = Vp.require("StadiumPack")
StadiumPack.DIR = "."
local normal = StadiumPack.load(6, false)
local shiny = StadiumPack.load(6, true)
ok(normal ~= nil, "the normal Charizard pack loads")
ok(shiny ~= nil, "and so does the shiny one")
if normal and shiny then
eq(shiny.shiny, true, "the shiny model is flagged as such")
eq(normal.shiny, nil, "and the normal one is not")
ok(normal ~= shiny, "they are SEPARATE models, not one shared table")
eq(#normal.prims, #shiny.prims, "same geometry")
eq(normal.texCount, shiny.texCount, "same texture count")
-- the point of the whole exercise: different pixels
local differs = false
for i = 1, math.min(#normal.textures, #shiny.textures) do
if normal.textures[i].rgba ~= shiny.textures[i].rgba then
differs = true
break
end
end
ok(differs, "and different texels")
end
-- a species with no shiny pack must fall back rather than vanish: that is
-- what an install from before rev 3 looks like
local missing = StadiumPack.load(999, true)
eq(missing, nil, "an out-of-range species is still nil")
end
-- ------- end to end, through the engine's own constructor
--
-- The wrap is the whole feature: if Pokemon.new does not carry the verdict,
-- nothing downstream has anything to draw. Exercised against the real
-- constructor and the fixture dataset rather than a stub, because what is
-- being tested is precisely that we hooked the thing the game calls.
do
local okKit, T = pcall(require, "tests.modkit")
local Data = okKit and T.fixtures and T.fixtures.load()
local okPk, Pokemon = pcall(require, "src.pokemon.Pokemon")
local species = Data and Data.pokemon
and (Data.pokemon.PIKACHU and "PIKACHU"
or next(Data.pokemon))
if not (okKit and okPk and species) then
io.write("note: no fixture dataset; skipped the end-to-end check\n")
else
local ShinyBattle = V.require("ShinyBattle")
ShinyBattle.install()
ShinyBattle.install() -- twice: the wrap must not stack
local saved = Shiny.ODDS_DENOM
Shiny.setOdds(1)
local m = Pokemon.new(Data, species, 7)
ok(Shiny.isShiny(m), "a mon built at odds 1 comes out shiny")
eq(m.shiny, true, "and carries the flag")
eq(m.hp, m.stats.hp, "and is at full health despite the restat")
ok(Stats.isShiny(m.dvs), "and the ENGINE agrees it is shiny")
-- and at long odds it essentially never is: 400 draws at 1/8192 would
-- fire about 5% of the time, so a single failure here is signal
Shiny.setOdds(8192)
local shinies = 0
for _ = 1, 400 do
if Shiny.isShiny(Pokemon.new(Data, species, 7)) then
shinies = shinies + 1
end
end
ok(shinies <= 2, ("400 mons at 1/8192 produced %d shinies")
:format(shinies))
Shiny.setOdds(saved)
end
end
io.write(("\n%d passed, %d failed\n"):format(pass, fail))
os.exit(fail == 0 and 0 or 1)
+157
View File
@@ -0,0 +1,157 @@
-- Driver: one STADIUM screenshot per species, every one of the 151 standing
-- on the ENEMY mark in its standby loop.
--
-- stadium_shots.lua photographs a handful of deliberately awkward cases; this
-- is the exhaustive sweep behind it. What it exists to catch is the class of
-- fault that is per-MODEL and invisible to the headless QA pass -- a texture
-- that comes out magenta, a mon standing in the floor or floating over it, a
-- silhouette that is subtly the wrong shape -- none of which is a number
-- stadium_anim_qa.lua can measure.
--
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \
-- POKEPORT_SPEED=4 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \
-- lovec.exe .
--
-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT.
--
-- ------- everything but the foe is nailed down
--
-- The whole value of 151 shots is that they are comparable, so the staging is
-- identical in every one: same map, same cells, same hour (the day/night
-- cycle would otherwise relight every shot), same rung, and the same small
-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest
-- model in the set and so hides the least of the arena behind it. Whatever
-- differs between two of these shots is the foe.
--
-- ------- the model/sprite verdict is the other half
--
-- A species whose pack will not load, or whose animation data the packer
-- declined, does not error: StadiumMon.setSpecies answers false and the mode
-- quietly stands the Game Boy's flat pic on the tile instead. That is the
-- correct behaviour and it is nearly invisible in a screenshot at a glance --
-- which is exactly why it needs counting rather than looking. Stadium.showing
-- is asked for every species and the ones that decline are listed at the end.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify"
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 Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Pack = lib.require("StadiumPack")
local Install = lib.require("StadiumInstall")
-- every real species the merged data carries, in dex order
local species = {}
for id, def in pairs(game.data.pokemon) do
if type(id) == "string" and type(def) == "table"
and def.dex and def.dex >= 1 and def.dex <= 151 then
species[#species + 1] = { id = id, dex = def.dex }
end
end
table.sort(species, function(a, b) return a.dex < b.dex end)
game.save.player.name = "RED"
-- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the
-- mod's update hook on the first frame the overworld is the top state, so
-- waiting for it before teleporting waits forever.
U.teleport(game, "ROUTE_1", 5, 8, "down")
local waited = 0
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do
U.wait(10)
waited = waited + 10
end
if waited > 0 then
U.log(("waited %.1fs for the model build -- %s")
:format(waited / 60, tostring(Install.status.state)))
end
Battles.setting:setValue("stadium", game)
Battles.backSetting:setValue(false, game)
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
local have = 0
for dex = 1, 151 do
if Pack.available(dex) then have = have + 1 end
end
U.log(("%d species, %d/151 packs on disk, rung = %s")
:format(#species, have, tostring(Battles.setting:get())))
-- let the neighbourhood's meshes land, so the first fight opens on the
-- real arena rather than the flat fallback
U.wait(90)
local asPic, stuck, missed = {}, {}, {}
for _, s in ipairs(species) do
-- the player's side is a constant (see the header), so the only thing
-- that changes between two shots is the Pokemon being verified
game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) }
local battle = BattleState.newWild(game, s.id, 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe and the intro chatter, then far enough past it that the
-- player's own Pokemon has been sent out -- before that the player's
-- side is legitimately the trainer's back sprite and the foe is still
-- sliding in
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
if battle.phase ~= "menu" then
stuck[#stuck + 1] = s.id
U.log(("STUCK before menu: %s (phase %s)")
:format(s.id, tostring(battle.phase)))
end
-- and let the send-out beat finish so the mon is standing still in its
-- standby loop rather than mid-arrival
U.wait(45)
-- model or flat pic? asked here, with the fight actually on screen
if not Stadium.showing("enemy") then
asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id)
end
local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())
if not U.shot(game, path) then
missed[#missed + 1] = s.id
end
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log(("---- %d shots -> %s"):format(#species - #missed, DIR))
if #asPic > 0 then
U.log(("%d species declined to a model and were shot as FLAT PICS: %s")
:format(#asPic, table.concat(asPic, ", ")))
else
U.log("all species stood as MODELS -- none fell back to a flat pic")
end
if #stuck > 0 then
U.log(("%d never reached the battle menu: %s")
:format(#stuck, table.concat(stuck, ", ")))
end
if #missed > 0 then
U.log(("%d screenshots did not reach disk: %s")
:format(#missed, table.concat(missed, ", ")))
end
U.log("done -- " .. DIR)
end
+622
View File
@@ -0,0 +1,622 @@
-- STADIUM battles: every Pokemon, every animation, every frame.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_anim_qa.lua \
-- [--packs=DIR] [--dex=N[,N...]] [--step=0.5] [--quiet]
--
-- Run from the PROJECT ROOT.
--
-- ------- what this is for
--
-- A battle asks a species for one of its animations and then poses, skins,
-- re-textures and draws it sixty times a second. Nothing in that chain is
-- exercised by the pack probe, which reads the format and walks a bind pose,
-- and nothing in it is exercised by the shot drivers, which show one species
-- in one animation at a time. So the failures that only some species have --
-- a texture index nothing maps, an animation that throws a bone into orbit,
-- a track that indexes off its own end -- have had no way of being found
-- except by a player calling that Pokemon out.
--
-- This is that sweep, headless: the REAL StadiumPack, the REAL StadiumRig
-- and the REAL StadiumMon over stubs for the three things a graphics context
-- provides (a mesh, an image, the draw call). Every species, every animation
-- it carries, every frame of it, plus the battle state machine over every
-- one of the 165 move slots.
--
-- ------- the stubs are not lenient
--
-- The fake mesh and the fake image behave like LOVE's do in the one way that
-- matters: an object that has been released THROWS when it is used, with
-- LOVE's own message. That is deliberate -- a released texture reaching a
-- draw call is a real failure mode of this mode (see the LRU note in
-- StadiumPack), and a stub that quietly accepted one would hide exactly the
-- class of bug this sweep exists to find.
local args = {}
local only = nil
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v elseif a == "--quiet" then args.quiet = "1" end
end
if args.dex then
only = {}
for n in args.dex:gmatch("%d+") do only[#only + 1] = tonumber(n) end
end
local MOD = "mods/DramaticShapeVoxelMod"
-- Where the .dsm packs are. The mod builds them into LOVE's save directory
-- on first run (StadiumInstall), which is where a developer machine actually
-- has them; a checkout that has run tools/stadium_pack.py has them in the
-- mod instead. Both are tried, and --packs overrides.
local PACK_DIRS = {}
local function lookIn(dir)
if dir and dir ~= "" then PACK_DIRS[#PACK_DIRS + 1] = dir end
end
lookIn(args.packs)
lookIn(os.getenv("APPDATA")
and os.getenv("APPDATA") .. "/LOVE/pokemon-love2d/dramatic_shape/stadium")
lookIn(os.getenv("HOME")
and os.getenv("HOME")
.. "/.local/share/love/pokemon-love2d/dramatic_shape/stadium")
lookIn(MOD .. "/assets/stadium")
-- How far apart the sampled frames are, in the animation's own 30 Hz frames.
-- A half frame rather than a whole one because the rig INTERPOLATES between
-- entries, and the blend has guards of its own that only run when k > 0 --
-- sampling on whole frames alone would never execute them.
local STEP = tonumber(args.step or "0.5")
-- How much taller than its own bind pose a posed model may stand before it
-- is called broken. Generous on purpose: a Pokemon that rears up, a wing
-- that opens, a Gyarados that lunges are all genuinely several times their
-- standing height. What this catches is the other thing -- a bone thrown
-- hundreds of units off the body, which comes out in the dozens.
local EXPLODE = 6.0
-- The texture index of a primitive the source says has no texture. The pack
-- stores indices one-based, so the packer's 0xFFFF sentinel arrives as this.
local UNTEXTURED = 0xFFFF + 1
-- species -> true, for the ones that carry such a primitive. Counted rather
-- than reported (see the texture check).
local untextured = {}
-- species the rig refuses to anchor because their own standby loop says the
-- body estimate cannot be trusted (StadiumRig.ANCHOR_STEADY)
local unanchored = {}
-- How far the mode lets an animation carry the Pokemon off its tile, in body
-- heights. Read from StadiumMon rather than repeated, so the sweep cannot go
-- on passing against a number the mode has since changed.
local TRAVEL = nil
-- ------- the harness
package.path = "./?.lua;./?/init.lua;" .. package.path
local packDir = nil
for _, dir in ipairs(PACK_DIRS) do
if dir and dir ~= "" then
local fp = io.open(dir .. "/001.dsm", "rb")
if fp then fp:close() packDir = dir break end
end
end
if not packDir then
print("no .dsm packs found -- pass --packs=DIR")
print("looked in:")
for _, dir in ipairs(PACK_DIRS) do
if dir and dir ~= "" then print(" " .. dir) end
end
os.exit(1)
end
local function readFile(path)
local fp = io.open(path, "rb")
if not fp then return nil end
local bytes = fp:read("*a")
fp:close()
return bytes
end
-- ------- the three things a graphics context provides
--
-- A released object throws, exactly as LOVE's does. See the header.
local RELEASED = "Cannot use object after it has been released."
local function checkLive(obj)
if obj and obj.released then error(RELEASED, 0) end
end
local function newFakeImage(w, h)
local img = { w = w, h = h, released = false }
function img:setFilter() checkLive(self) end
function img:setWrap() checkLive(self) end
function img:release() self.released = true return true end
function img:type() return "Image" end
return img
end
local function newFakeMesh(format, rows)
local mesh = { released = false, verts = rows }
function mesh:setVertexMap() checkLive(self) end
function mesh:setVertices(v) checkLive(self) self.verts = v end
function mesh:setTexture(t) checkLive(self) checkLive(t) self.tex = t end
function mesh:release() self.released = true return true end
function mesh:type() return "Mesh" end
return mesh
end
love = {
filesystem = {
getInfo = function(rel)
-- the mod asks for "dramatic_shape/stadium/NNN.dsm" relative to the
-- save directory; the sweep serves that out of packDir
local name = rel:match("([^/]+)$")
local fp = io.open(packDir .. "/" .. name, "rb")
if not fp then return nil end
fp:close()
return { type = "file" }
end,
read = function(rel)
local name = rel:match("([^/]+)$")
return readFile(packDir .. "/" .. name)
end,
},
graphics = {
newMesh = function(format, rows, mode, usage) return newFakeMesh(format, rows) end,
newImage = function(data) return newFakeImage(data.w, data.h) end,
},
image = {
newImageData = function(w, h, fmt, bytes) return { w = w, h = h } end,
},
}
local V = {}
local modules = {}
V.mod = {
log = {
warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end,
info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end,
},
read = function(_, rel) return readFile(MOD .. "/" .. rel) end,
}
function V.require(name)
if modules[name] then return modules[name] end
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
modules[name] = chunk(V)
return modules[name]
end
-- Voxel3D, to the extent the rig touches it. `draw` mirrors the real one's
-- first act -- binding the texture to the mesh -- because that is the line a
-- released texture dies on (Voxel3D.draw), and the whole point of the stub
-- is that it dies there here too.
local drawn, skipped = 0, 0
modules.Voxel3D = {
FORMAT = {},
seams = function() end,
glass = function() end,
blend = function() end,
draw = function(mesh, texture, model, pull, sunModel)
if not mesh then return end
if texture then mesh:setTexture(texture) end
drawn = drawn + 1
end,
}
-- the shadow pass, same shape
local shadowMap = { draw = function(mesh, texture, model)
if texture then mesh:setTexture(texture) end
end }
local Pack = V.require("StadiumPack")
local Rig = V.require("StadiumRig")
local Mon = V.require("StadiumMon")
TRAVEL = Mon.TRAVEL
-- ------- findings
local findings = {} -- kind -> { {dex=, anim=, frame=, detail=} ... }
local kinds = {} -- kind order, first seen first
local function report(kind, dex, anim, frame, detail)
if not findings[kind] then
findings[kind] = {}
kinds[#kinds + 1] = kind
end
local list = findings[kind]
list[#list + 1] = { dex = dex, anim = anim, frame = frame, detail = detail }
end
-- ------- one animation, frame by frame
-- Where the BODY of a posed rig is: the bone origins averaged, weighted by
-- how many vertices each bone moves.
--
-- The same QUANTITY StadiumRig.anchor corrects, written out a second time
-- rather than called: a check that shared the code it is checking would agree
-- with it by construction. It must be the same quantity, though -- an earlier
-- version of this measured the MEDIAN instead, and reported 27,965 findings
-- purely because it was asking a different question than the anchor answers.
local function bodyCentreOf(rig)
local m = rig.model
local w, total = m.qaWeights, m.qaWeightTotal
if not w then
w, total = {}, 0
for b = 1, m.boneCount do w[b] = 0 end
for _, prim in ipairs(m.prims) do
for k = 1, prim.vertCount do
local b = prim.bone[k]
if w[b] then w[b] = w[b] + 1; total = total + 1 end
end
end
m.qaWeights, m.qaWeightTotal = w, total
end
if not (total > 0) then return 0, 0, 0 end
local x, y, z, d = 0, 0, 0, rig.drawM
for b = 1, m.boneCount do
local q = w[b]
if q > 0 then
local o = (b - 1) * 12
x = x + d[o + 4] * q
y = y + d[o + 8] * q
z = z + d[o + 12] * q
end
end
return x / total, y / total, z / total
end
local function bboxOf(rig)
local lo, hi = math.huge, -math.huge
local bad = false
for _, part in ipairs(rig.parts) do
local rows = part.rows
for k = 1, part.prim.vertCount do
local row = rows[k]
local x, y, z = row[1], row[2], row[3]
-- NaN is the only value not equal to itself; infinity survives that
-- test and has to be asked about separately
if x ~= x or y ~= y or z ~= z
or x == math.huge or x == -math.huge
or y == math.huge or y == -math.huge
or z == math.huge or z == -math.huge then
bad = true
else
if y < lo then lo = y end
if y > hi then hi = y end
end
end
end
if lo > hi then return 0, bad end
return hi - lo, bad
end
-- A species' worth of work. Returns the number of frames stepped.
local function sweepSpecies(dex)
local model = Pack.load(dex)
if not model then
-- say WHY, or a sweep that ran out of file handles reads as 108 broken
-- Pokemon
local _, err = io.open(("%s/%03d.dsm"):format(packDir, dex), "rb")
report("pack did not load", dex, nil, nil,
("StadiumPack.load returned nil (io.open says: %s)")
:format(tostring(err)))
return 0
end
local rig = Rig.new(model)
if not rig then
report("rig would not build", dex, nil, nil,
("%d prims, %d bones"):format(model.primCount, model.boneCount))
return 0
end
-- the bind pose, as the yardstick every posed frame is measured against
rig:measureBind()
rig:pose(nil, 0, false)
rig:skin(0)
local bind = bboxOf(rig)
if not (bind > 0) then bind = 1 end
-- and where the bind pose puts the BODY, for the travel check below. The
-- tracks are in raw units, before the model_root scale model.height is
-- measured after.
if model.anchorOk == false then unanchored[dex] = true end
local bcx, bcy, bcz = bodyCentreOf(rig)
local rawHeight = (model.height or 0)
/ ((model.rootScale or 0) > 0 and model.rootScale or 1)
if not (rawHeight > 0) then rawHeight = 1 end
local steps = 0
for index, anim in ipairs(model.anims) do
local name = anim.name or ("#" .. index)
local frames = anim.frames or 1
if frames < 1 then
report("animation has no frames", dex, name, nil,
("frames=%d"):format(frames))
end
-- the loop start has to be inside the animation or the wrap arithmetic
-- lands outside the track arrays
local loop = anim.loopStart or 0
if loop < 0 or loop >= frames then
report("loopStart out of range", dex, name, nil,
("loopStart=%d of %d frames"):format(loop, frames))
end
if anim.aux and not (model.auxAnims and model.auxAnims[anim.aux]) then
report("aux animation missing", dex, name, nil,
("anim.aux=%s, %d aux animations")
:format(tostring(anim.aux), model.auxCount or 0))
end
-- Both ways round: a standby loop WRAPS at the far end and a faint HOLDS
-- on its last frame, and the two take different branches of the pose
-- walk. Sampled a little past the end on purpose -- that is where a
-- battle actually leaves them.
for _, wrap in ipairs({ true, false }) do
local f = 0
while f < frames + 2 do
local ok, err = pcall(function()
rig:pose(index, f, wrap)
rig:skin(0.5)
rig:textures(anim.aux)
rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0)
end)
steps = steps + 1
if not ok then
report("threw while playing", dex, name, f, tostring(err))
break
end
-- ------- does it stay on its tile?
--
-- Stadium's animations were authored for a camera that FOLLOWED the
-- Pokemon around a stage; this mode's camera is solved to hold two
-- fixed map cells and does not move. So an animation that walks the
-- body several of its own heights away does not look dramatic here,
-- it looks like the Pokemon is missing -- which is what sending out
-- a Farfetch'd did for three and a half seconds.
--
-- StadiumRig.anchor takes the excess back out, and this is the check
-- that it did: measured AFTER the anchor, the same way the mode
-- draws it, so what is reported is what a player would actually see.
rig:anchor(TRAVEL)
local cx, cy, cz = bodyCentreOf(rig)
local drift = (((cx - bcx) ^ 2 + (cy - bcy) ^ 2 + (cz - bcz) ^ 2) ^ 0.5)
/ rawHeight
-- a species the rig has judged unmeasurable is deliberately NOT
-- anchored (StadiumRig.measureBind), so of course it still travels --
-- reporting that would be reporting a decision as a defect
if model.anchorOk ~= false and drift > TRAVEL * 1.05 then
report("animation walks the Pokemon off its tile", dex, name, f,
("body centre %.1f body-heights from where it started")
:format(drift))
end
local h, bad = bboxOf(rig)
if bad then
report("posed vertex is NaN or infinite", dex, name, f, "")
elseif h > bind * EXPLODE then
report("pose flies apart", dex, name, f,
("%.0f units tall against a %.0f-unit bind pose (%.1fx)")
:format(h, bind, h / bind))
end
-- Every piece of the Pokemon has to have a texture to be drawn with,
-- and one that resolves to nothing is a limb that is simply not
-- there -- EXCEPT where the source says it has none.
--
-- StadiumPack stores the texture index one-based (`u16 + 1`), so the
-- packer's 0xFFFF "this primitive is untextured" sentinel arrives
-- here as 65536. That is 39 primitives across 37 species, 1.6% of the
-- set's vertices, and every one of them has all-zero UVs -- they are
-- flat-shaded geometry in the original, not a texture that went
-- missing. Reported as a finding they were 104,728 lines of noise
-- (one per prim per sampled frame) burying two real bugs.
--
-- Counted rather than dropped: "how much of this set is untextured"
-- is worth knowing, and a number that suddenly moves is worth seeing.
for i, part in ipairs(rig.parts) do
if not part.texture then
if part.prim.tex == UNTEXTURED then
untextured[dex] = true
else
report("part has no texture", dex, name, f,
("prim %d wants texture %s of %d")
:format(i, tostring(part.prim.tex), model.texCount or 0))
end
end
end
f = f + STEP
end
end
end
-- ------- and the same species as a BATTLE drives it
--
-- The sweep above plays the animations; this plays the STATE MACHINE, which
-- is what a fight actually touches: the context slots behind idle, the
-- entrance, the two reactions and the collapse, and then all 165 move slots
-- -- because a move's animation is looked up by move id in a table the
-- species carries, and an index in that table that points nowhere is a
-- crash on the frame that move is used.
local mon = Mon.new("player")
mon.rig, mon.model, mon.species = rig, model, dex
mon.state, mon.anim, mon.time = nil, nil, 0
for _, state in ipairs({ "idle", "entrance", "attack", "faint" }) do
local ok, err = pcall(function()
mon:play(state)
-- a whole second of it at 60 Hz, which is what the fight does
for _ = 1, 60 do
mon:update(1 / 60)
mon:build()
end
end)
if not ok then
report("threw while playing", dex, state, nil, tostring(err))
end
end
for moveIndex = 1, Pack.N_MOVES do
local slot = model.moveAnim[moveIndex]
if slot and slot ~= Pack.NONE then
if not model.anims[slot + 1] then
report("move points at an animation that is not there", dex,
("move %d"):format(moveIndex), nil,
("anim index %d of %d"):format(slot + 1, model.animCount or 0))
else
local aux = model.moveAux[moveIndex]
if aux and aux >= 0 and not (model.auxAnims and model.auxAnims[aux + 1]) then
report("move points at an aux that is not there", dex,
("move %d"):format(moveIndex), nil,
("aux index %d of %d"):format(aux + 1, model.auxCount or 0))
end
local ok, err = pcall(function()
mon:attack(moveIndex)
for _ = 1, 30 do
mon:update(1 / 60)
mon:build()
end
end)
if not ok then
report("threw while playing", dex, ("move %d"):format(moveIndex),
nil, tostring(err))
end
end
end
end
rig:release()
return steps
end
-- ------- the sweep
local list = only
if not list then
list = {}
for dex = 1, 151 do list[dex] = dex end
end
local started = os.clock()
local steps = 0
local staticPose = {}
for _, dex in ipairs(list) do
local m = Pack.load(dex)
-- SKIPPED, not swept. The packer measures each species' standby loop
-- against its own bind pose and flags the ones whose animation data is
-- corrupt at source, and StadiumMon declines those outright -- they stand
-- as flat battle pics and no frame of them is ever posed in a game. Sweeping
-- them anyway produced 356 of the 362 "pose flies apart" findings, which is
-- a report that is mostly about Pokemon this mode does not draw.
if m and m.staticPose then
staticPose[#staticPose + 1] = dex
else
local ok, err = pcall(function() steps = steps + sweepSpecies(dex) end)
if not ok then
report("the sweep itself threw", dex, nil, nil, tostring(err))
end
end
if not args.quiet and dex % 10 == 0 then
io.write((" ... %d/%d %.0f MB\n")
:format(dex, #list, collectgarbage("count") / 1024))
io.flush()
end
end
-- ------- the LRU, which is the one failure a frame sweep cannot reach
--
-- A model is evicted by SPECIES COUNT, not by whether anything is still
-- standing on it -- so a battle that has seen five species while two of them
-- are on the field releases the textures of a Pokemon that is still being
-- drawn. That is not something playing one species' animations can produce;
-- it takes a sixth load. Reproduced here directly.
local function sweepEviction()
local held = {}
-- two Pokemon out, as a battle has
for _, dex in ipairs({ 1, 4 }) do
local model = Pack.load(dex)
local rig = model and Rig.new(model)
if rig then held[#held + 1] = { dex = dex, model = model, rig = rig } end
end
if #held < 2 then return end
for _, h in ipairs(held) do
h.rig:pose(1, 0, true)
h.rig:skin(0)
h.rig:textures(nil)
end
-- and then the fight sees more species than the cache keeps
for _, dex in ipairs({ 7, 10, 13, 16, 19, 25 }) do Pack.load(dex) end
for _, h in ipairs(held) do
local ok, err = pcall(function()
h.rig:textures(nil)
h.rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0)
end)
if not ok then
report("threw after the pack cache evicted a model still on the field",
h.dex, "idle", nil, tostring(err))
end
end
for _, h in ipairs(held) do h.rig:release() end
end
sweepEviction()
local elapsed = os.clock() - started
-- ------- what it found
print("")
print(("stadium animation QA: %d species, %d posed frames, %.1fs")
:format(#list, steps, elapsed))
print(("packs: %s"):format(packDir))
local nUntextured = 0
for _ in pairs(untextured) do nUntextured = nUntextured + 1 end
if nUntextured > 0 then
print(("species with flat-shaded (untextured) primitives: %d -- expected, "
.. "the source has no texture for those"):format(nUntextured))
end
local anchorList = {}
for dex in pairs(unanchored) do anchorList[#anchorList + 1] = dex end
table.sort(anchorList)
if #anchorList > 0 then
print(("not anchored (standby loop too unsteady to measure, so they travel "
.. "as authored): %s"):format(table.concat(anchorList, " ")))
end
if #staticPose > 0 then
print(("staticPose (declined by the packer, never drawn, not swept): %s")
:format(table.concat(staticPose, " ")))
end
print("")
local total = 0
for _, kind in ipairs(kinds) do total = total + #findings[kind] end
if total == 0 then
print("no findings")
os.exit(0)
end
for _, kind in ipairs(kinds) do
local list2 = findings[kind]
print(("---- %s (%d)"):format(kind, #list2))
-- one line per species per kind, with the first example and a count, so a
-- fault that spans every frame of an animation reads as one fault
local seen, order = {}, {}
for _, f in ipairs(list2) do
local key = ("%03d|%s"):format(f.dex or 0, tostring(f.anim))
if not seen[key] then
seen[key] = { n = 0, f = f }
order[#order + 1] = key
end
seen[key].n = seen[key].n + 1
end
for _, key in ipairs(order) do
local e = seen[key]
print((" %03d %-14s %s%s%s")
:format(e.f.dex or 0, tostring(e.f.anim),
e.f.frame and ("frame %.1f: "):format(e.f.frame) or "",
e.detail or e.f.detail or "",
e.n > 1 and (" (x%d)"):format(e.n) or ""))
end
print("")
end
print(("%d findings in %d kinds"):format(total, #kinds))
os.exit(total > 0 and 1 or 0)
+149
View File
@@ -0,0 +1,149 @@
-- What the model build COSTS -- the numbers that decide whether it can run
-- on a phone.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_budget_test.lua [--rom=PATH]
--
-- Run from the PROJECT ROOT.
--
-- ------- why this is a test and not a note
--
-- The extraction is pure Lua on purpose -- no FFI, no native helper, no
-- second process -- specifically so it can run everywhere LOVE does, phones
-- included. That claim is about MEMORY as much as about which functions
-- exist: a desktop will not notice a transient peak that would have a mobile
-- OS kill the process, and the peak is not visible by reading the code.
--
-- So this measures it, and fails if the two things that must stay bounded
-- stop being bounded:
--
-- the WORKING SET must not grow with the number of species done, or the
-- build gets heavier the longer it runs and dies somewhere in the 140s
--
-- the PEAK must stay under a budget a phone can be expected to have
-- spare, on top of a game that is already running
--
-- The ROM itself is most of the floor and cannot be avoided -- the archive is
-- addressed by absolute offset -- so it is reported separately from the
-- per-species work built on top of it.
local args = {}
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v end
end
local MOD = "mods/DramaticShapeVoxelMod"
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
-- How much headroom the whole build may take on top of the ROM, in MB. Sized
-- against the smallest thing this is expected to run on rather than against
-- what is convenient: a mid-range phone from several years ago, with a game
-- already resident.
local PEAK_BUDGET_MB = 220
-- How much the working set may drift between the first ten species and the
-- last ten, in MB. Not zero -- species differ in size by a factor of ten, and
-- the collector is not obliged to run on any particular schedule -- but a
-- genuine leak shows up here as tens of megabytes.
local DRIFT_BUDGET_MB = 24
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
loaded[name] = chunk(V)
end
return loaded[name]
end
V.mod = { log = { warn = function() end, info = function() end } }
local StadiumRom = V.require("StadiumRom")
local StadiumBuild = V.require("StadiumBuild")
local function mb()
return collectgarbage("count") / 1024
end
-- A single collectgarbage() is not guaranteed to finish a cycle, and a
-- half-finished one reads tens of megabytes high -- which showed up here as
-- "settled" figures BELOW the baseline they were measured against. Run it
-- until the number stops moving.
local function settle()
local last = mb()
for _ = 1, 8 do
collectgarbage("collect")
local now = mb()
if now >= last - 0.05 then return now end
last = now
end
return mb()
end
-- The baseline is taken BEFORE the ROM is read, or the file is already on the
-- heap when it is measured and the ROM appears to cost nothing.
local base = settle()
local fp = io.open(ROM, "rb")
if not fp then
io.stderr:write("no ROM at " .. ROM .. "\n")
os.exit(2)
end
local romBytes = fp:read("*a")
fp:close()
local rom = assert(StadiumRom.open(romBytes))
local withRom = settle()
local peak, settled = withRom, {}
local bytes = 0
local t0 = os.clock()
for fileno = 0, StadiumRom.N_POKEMON - 1 do
local res = assert(StadiumBuild.species(rom, fileno))
bytes = bytes + #res.bytes
-- the live peak, before the collector has been asked for anything: this is
-- what the allocator actually had to find
local live = mb()
if live > peak then peak = live end
-- and the settled working set, which is what must not creep
settled[fileno + 1] = settle()
end
local dt = os.clock() - t0
local function avg(from, to)
local sum = 0
for i = from, to do sum = sum + settled[i] end
return sum / (to - from + 1)
end
local first = avg(1, 10)
local last = avg(#settled - 9, #settled)
local drift = last - first
io.write(("ROM %6.1f MB (the file, held for the whole build)\n")
:format(withRom - base))
io.write(("peak working set %6.1f MB (over the ROM)\n"):format(peak - withRom))
io.write(("settled, first 10 %6.1f MB\n"):format(first - withRom))
io.write(("settled, last 10 %6.1f MB\n"):format(last - withRom))
io.write(("drift %+6.1f MB (budget %.0f)\n")
:format(drift, DRIFT_BUDGET_MB))
io.write(("total peak %6.1f MB (budget %.0f)\n")
:format(peak, PEAK_BUDGET_MB))
io.write(("output %6.1f MB in %.1fs (%.0f ms a species)\n")
:format(bytes / 1e6, dt, dt * 1000 / StadiumRom.N_POKEMON))
local fail = false
if peak > PEAK_BUDGET_MB then
io.write(("FAIL peak %.1f MB is over the %.0f MB budget\n")
:format(peak, PEAK_BUDGET_MB))
fail = true
end
if drift > DRIFT_BUDGET_MB then
io.write(("FAIL the working set grew %.1f MB across the run -- something is "
.. "being retained per species\n"):format(drift))
fail = true
end
if fail then os.exit(1) end
io.write("PASS -- bounded working set, peak within budget\n")
+57
View File
@@ -0,0 +1,57 @@
-- Driver: the send-out ENTRANCE, frame by frame, for the two species the
-- animation QA sweep flags as flying apart in that animation alone.
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 lib = game.mods.exports.DRAMATIC_SHAPE.lib
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Install = lib.require("StadiumInstall")
game.save.player.name = "RED"
U.teleport(game, "ROUTE_1", 5, 8, "down")
local waited = 0
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do U.wait(10); waited = waited + 10 end
while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end
Battles.setting:setValue(os.getenv("DS_RUNG") or "stadiumB", game)
Battles.backSetting:setValue(false, game)
lib.require("DayNight").setting:setValue("day", game)
U.wait(60)
for _, name in ipairs({ "FARFETCHD", "DEWGONG" }) do
game.save.party = { Pokemon.new(game.data, name, 40) }
local battle = BattleState.newWild(game, "PIKACHU", 20)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- ONE loop from the first frame: the send-out needs taps to get past
-- the intro text, and the entrance starts the instant it happens -- so
-- watching and tapping have to be the same loop or the animation is over
-- before the watching begins (which is exactly what a split loop did).
local shot, sawEntrance = 0, false
for f = 1, 900 do
U.wait(1)
local anim = Stadium.animOf("player")
if anim == "entrance" then
sawEntrance = true
if f % 14 == 0 and shot < 8 then
shot = shot + 1
U.shot(game, ("%s/%s_entrance_%d.png"):format(DIR, name:lower(), shot))
end
elseif sawEntrance then
break
end
if not sawEntrance and f % 12 == 0 then U.tap(game, "a") end
end
U.log(("%s: took %d entrance stills, anim now %s")
:format(name, shot, tostring(Stadium.animOf("player"))))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+84
View File
@@ -0,0 +1,84 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local lib = game.mods.exports.DRAMATIC_SHAPE.lib
local Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Install = lib.require("StadiumInstall")
U.teleport(game, "ROUTE_1", 5, 8, "down")
local w = 0
while (Install.pending() or Install.status.state == "building") and w < 5400 do
U.wait(10); w = w + 10
end
while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end
Battles.setting:setValue("stadium", game)
Battles.backSetting:setValue(false, game)
U.wait(60)
local marks = {}
local inner = BattleState.startGrowIn
BattleState.startGrowIn = function(self, b)
marks[#marks + 1] = { side = (b == self.player) and "player" or "enemy" }
return inner(self, b)
end
game.save.party = { Pokemon.new(game.data, "PIDGEY", 30) }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local seen, lastState, lastGrow, nMark = {}, "?", false, 0
local shotAt = {}
for f = 1, 500 do
U.wait(1)
if f % 10 == 0 and battle.phase ~= "menu" then U.tap(game, "a") end
local st = Stadium.animOf("player") or "-"
local grow = battle.growIn ~= nil
if #marks > nMark then
nMark = #marks
U.log(("f=%3d >> startGrowIn(%s) player model present: %s, state %s")
:format(f, marks[nMark].side, tostring(Stadium.showing("player")),
st))
end
local poof = (battle.animPlaying and battle.animName == "POOF_ANIM")
and true or false
local key = ("%s|%s|%s|%s|%s|%s|%s|%s|%s"):format(st,
tostring(math.floor((Stadium.scaleOf("player") or 1) * 20)),
tostring(poof), tostring(battle.sendingOut),
tostring(battle.phase), tostring(Stadium.showing("player")),
tostring(battle.showPlayerBack), tostring(battle.playerBackPic ~= nil),
tostring((battle.introSlide or 0) > 0))
if key ~= lastState then
U.log(("f=%3d state=%-9s sendingOut=%-5s poof=%-5s growIn=%-5s "
.. "growScale=%-6s model=%s")
:format(f, st, tostring(battle.sendingOut), tostring(poof),
tostring(grow), tostring(Stadium.scaleOf("player")),
tostring(Stadium.showing("player"))))
lastState = key
end
-- stills across the grow, so the ramp can be looked at and not just read
local sc = Stadium.scaleOf("player")
if Stadium.showing("player") and sc then
for _, want in ipairs({ 0.15, 0.45, 0.75, 1.0 }) do
if not shotAt[want] and sc >= want then
shotAt[want] = true
U.shot(game, ("%s/grow_%02d.png")
:format(os.getenv("SHOT_DIR") or ".", want * 100))
end
end
end
seen[st] = true
end
for i = 1, 4 do
U.shot(game, (os.getenv("SHOT_DIR") or ".") .. ("/pidgey_%d.png"):format(i))
U.wait(2)
end
local list = {}
for k in pairs(seen) do list[#list + 1] = k end
table.sort(list)
U.log("states the player's side passed through: " .. table.concat(list, ", "))
U.log("startGrowIn calls: " .. #marks)
U.log("done")
end
+175
View File
@@ -0,0 +1,175 @@
-- 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] [--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
-- roughly fifteen hundred lines of dense numeric Python -- an F3DEX2
-- interpreter, six texture codecs, a bit-packed animation sampler, a noise
-- generator and a binary writer. Reading a port of that twice does not
-- establish that it is right. Producing the same thirty-four megabytes,
-- byte for byte, does.
--
-- It is also the guard on the two of them DRIFTING. The packer and the
-- extractor have to keep agreeing about the format forever, and a change to
-- one that is not made to the other shows up here as a differing offset
-- rather than as a Pokemon that renders as noise three months later.
--
-- Headless: no LOVE, no graphics. Everything these four modules do is
-- arithmetic on strings.
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"
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
-- ------- the mod namespace, enough of it to load four modules
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
loaded[name] = chunk(V)
end
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")
-- ------- run
local function readFile(path)
local fp = io.open(path, "rb")
if not fp then return nil end
local data = fp:read("*a")
fp:close()
return data
end
local romBytes = readFile(ROM)
if not romBytes then
io.stderr:write("no ROM at " .. ROM .. "\n")
os.exit(2)
end
local rom, err = StadiumRom.open(romBytes)
if not rom then
io.stderr:write("could not open ROM: " .. tostring(err) .. "\n")
os.exit(2)
end
local only = nil
if args.only then
only = {}
for n in args.only:gmatch("%d+") do only[tonumber(n)] = true end
end
local checked, matched, missing, failed = 0, 0, 0, 0
local shinyOk, shinyMissing, shinyBad = 0, 0, 0
local firstBad = nil
local t0 = os.clock()
for fileno = 0, StadiumRom.N_POKEMON - 1 do
local ok, res = pcall(StadiumBuild.species, rom, fileno)
if not ok or not res then
-- res carries the message on the error path
io.write(("file %d: EXTRACT FAILED: %s\n"):format(fileno, tostring(res)))
failed = failed + 1
elseif not only or only[res.species] then
checked = checked + 1
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
missing = missing + 1
elseif want == res.bytes then
matched = matched + 1
else
-- where, exactly: the first differing byte localises a format mistake
-- far faster than "the file is wrong" does
local at = nil
local n = math.min(#want, #res.bytes)
for i = 1, n do
if want:sub(i, i) ~= res.bytes:sub(i, i) then at = i break end
end
io.write(("%03d.dsm DIFFERS: %d bytes vs %d, first at %s\n")
:format(res.species, #res.bytes, #want,
at and ("offset " .. at) or "(one is a prefix)"))
if not firstBad then firstBad = res.species end
end
end
end
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))
-- 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
io.write("FAIL\n")
os.exit(1)
+131
View File
@@ -0,0 +1,131 @@
-- Driver: import a Stadium ROM through the OPTIONS row, with the modal file
-- dialog stubbed out.
--
-- Everything except the dialog itself is the real path: the row is built the
-- way the OPTIONS menu builds it, its step() is pressed, StadiumRomPick reads
-- the file with io.open (love.filesystem cannot see an absolute path),
-- StadiumInstall builds from those bytes, and the loading screen goes up over
-- whatever asked. A modal dialog cannot be driven, so choose() is replaced
-- with the answer a player would have given it.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local lib = game.mods.exports.DRAMATIC_SHAPE.lib
local Pick = lib.require("StadiumRomPick")
local Install = lib.require("StadiumInstall")
local Pack = lib.require("StadiumPack")
local Battles = lib.require("OverworldBattle")
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(30)
-- FIRST let the automatic first-run build finish. A ROM is sitting in
-- baseroms/ in this checkout, so StadiumScreen.maybePush has already pushed
-- a loading screen and started one -- and import() refuses to start a
-- second while that runs, so every step below would silently no-op against
-- it. maybePush asks ONCE per boot, so once this is drained it stays
-- drained and the purge below cannot be undone behind our backs.
local drain = 0
while (Install.pending() or Install.status.state == "building")
and drain < 5400 do
U.wait(10); drain = drain + 10
end
while game.stack:top() and game.stack:top() ~= game.overworld do
U.wait(10)
end
U.log(("drained the automatic first-run build in %d frames"):format(drain))
-- back to a machine that has never built them, whatever earlier runs left
for _, name in ipairs(love.filesystem.getDirectoryItems(Pack.CACHE_DIR)) do
love.filesystem.remove(Pack.CACHE_DIR .. "/" .. name)
end
Install.forget()
Pack.forget()
local function settle(what)
local waited = 0
while Install.status.state == "building" and waited < 5400 do
U.wait(10)
waited = waited + 10
end
U.log(("%s: %d frames (%.1fs) -- state=%s error=%s")
:format(what, waited, waited / 60, tostring(Install.status.state),
tostring(Install.status.error)))
return waited
end
U.log(("picker available on this platform: %s"):format(
tostring(Pick.available())))
U.log(("BEFORE: models available = %s, 3D-BTL offers %d rungs")
:format(tostring(Install.available()), Battles.setting:rungs()))
local row = Pick.row()
if not row then U.log("no import row on this platform") return end
U.log(("row reads: %s = %s"):format(row.label, row.value()))
-- ------- the wrong file first, because a refusal must not leave a mess
U.log("-- picking a file that is not a Stadium ROM --")
Pick.choose = function() return "/not/a/real/path.z64" end
local realRead = Pick.read
Pick.read = function() return ("\x80\x37\x12\x40"):rep(4096) end
row.step(game)
settle("wrong file")
U.shot(game, ("%s/import_1_refused.png"):format(DIR))
U.log((" still not installed: %s, row still reads %s")
:format(tostring(not Install.available()), row.value()))
U.wait(300) -- let the failure notice time out and pop itself
-- ------- and now the real one
U.log("-- picking the real ROM --")
Pick.read = realRead
local abs = love.filesystem.getWorkingDirectory() .. "/baseroms/baserom.z64"
local picked = false
Pick.choose = function() picked = true return abs end
Install.status.state = "idle"
row.step(game)
U.log((" choose() called: %s, state = %s, loading screen on top: %s")
:format(tostring(picked), tostring(Install.status.state),
tostring(game.stack:top() ~= game.overworld)))
U.wait(30)
U.shot(game, ("%s/import_2_building.png"):format(DIR))
settle("real ROM")
U.wait(120)
U.log(("AFTER: models available = %s, 3D-BTL offers %d rungs, row reads %s")
:format(tostring(Install.available()), Battles.setting:rungs(),
row.value()))
Battles.setting:setValue("stadium", game)
U.log((" and 3D-BTL can now be set to STADIUM A: %s")
:format(tostring(Battles.setting:get() == "stadium")))
-- and the ROM was NOT copied anywhere: the only baserom physfs can see is
-- the one in the game folder this checkout already had, never a copy in
-- the save directory (which is where a mod's writes land)
local where = love.filesystem.getRealDirectory("baseroms/baserom.z64")
U.log((" the only ROM on the read path is the game folder's own: %s (%s)")
:format(tostring(where == love.filesystem.getWorkingDirectory()),
tostring(where)))
U.log((" the save directory has no baseroms folder: %s")
:format(tostring(love.filesystem.getInfo(
love.filesystem.getSaveDirectory() .. "/baseroms") == nil)))
-- ------- and the wrong file, now that a GOOD set is installed
--
-- The serious case. The marker is the only thing that makes 151 files on
-- disk count as installed, so a refusal that writes one anyway would
-- UNINSTALL a working set -- and the player would have pressed a row called
-- STADIUM ROM, picked the wrong file, and lost the models they already had.
U.log("-- picking the wrong file again, with models already installed --")
Pick.read = function() return ("€7@"):rep(4096) end
Install.status.state = "idle"
row.step(game)
settle("wrong file over a good install")
U.wait(300)
Install.forget()
Pack.forget()
U.log((" the good install SURVIVED: %s, row reads %s, 3D-BTL offers %d rungs")
:format(tostring(Install.available()), row.value(),
Battles.setting:rungs()))
U.log((" and a real model still loads: %s")
:format(tostring(Pack.load(25) ~= nil)))
U.log("done -- " .. DIR)
end
+115
View File
@@ -0,0 +1,115 @@
-- Probe: read the .dsm packs back with the mod's OWN reader and print what
-- it made of them, so the Lua side can be diffed against the packer's
-- --report output. Headless -- no love.graphics, no meshes.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_pack_probe.lua [dex ...]
--
-- Run from the repository root.
package.path = "./?.lua;./?/init.lua;" .. package.path
local ROOT = "mods/DramaticShapeVoxelMod"
-- the mod namespace lib/ modules expect (see main.lua), with just enough of
-- it for the two files under test
local V = {}
local modules = {}
V.mod = {
log = {
warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end,
info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end,
},
read = function(_, rel)
local fp = io.open(ROOT .. "/" .. rel, "rb")
if not fp then return nil end
local bytes = fp:read("*a")
fp:close()
return bytes
end,
}
function V.require(name)
if modules[name] then return modules[name] end
local chunk = assert(loadfile(ROOT .. "/lib/" .. name .. ".lua"))
modules[name] = chunk(V)
return modules[name]
end
-- StadiumRig pulls in Voxel3D, which needs love; the pose walk itself does
-- not, so the require is stubbed out to the one field it reads.
modules.Voxel3D = { FORMAT = {} }
local Pack = V.require("StadiumPack")
local Rig = V.require("StadiumRig")
local want = {}
for i = 1, #arg do want[#want + 1] = tonumber(arg[i]) end
if #want == 0 then want = { 25, 6, 130, 95, 50, 10, 143, 92, 41, 73 } end
print(("%-4s %-6s %6s %6s %6s %8s %8s %8s %8s")
:format("dex", "bones", "prims", "anims", "tex", "root", "height",
"floor", "radius"))
for _, dex in ipairs(want) do
local m = Pack.load(dex)
if not m then
print(("%-4d -- did not load"):format(dex))
else
print(("%-4d %-6d %6d %6d %6d %8.4f %8.2f %8.2f %8.2f")
:format(dex, m.boneCount, m.primCount, m.animCount, m.texCount,
m.rootScale, m.height, m.floor, m.radius))
-- the bind pose, walked by the REAL rig code on a bare instance (no
-- meshes, so no graphics context needed), measured the same way
-- tools/stadium_pack.py measures it. Agreement here means the byte
-- format, the bone tree, the rotation basis and the two-chain scale
-- split all survived the trip into Lua.
local rig = setmetatable({
model = m, pivotM = {}, drawM = {}, accX = {}, accY = {}, accZ = {},
parts = {},
}, Rig)
local function boxAt(anim, frame, wrap)
rig:pose(anim, frame, wrap)
local drw = rig.drawM
local lo, hi = math.huge, -math.huge
for _, prim in ipairs(m.prims) do
for k = 1, prim.vertCount do
local o = (prim.bone[k] - 1) * 12
local x, y, z = prim.px[k], prim.py[k], prim.pz[k]
local wy = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z
+ drw[o + 8]
wy = wy * m.rootScale
if wy < lo then lo = wy end
if wy > hi then hi = wy end
end
end
return hi - lo, lo
end
local h, f = boxAt(nil, 0, false)
print((" bind pose walked in Lua: height %8.2f floor %8.2f "
.. "(header says %8.2f / %8.2f)%s")
:format(h, f, m.height, m.floor,
(math.abs(h - m.height) < 0.5
and math.abs(f - m.floor) < 0.5) and " OK" or " MISMATCH"))
-- the animation tables, decoded lazily like the game does it
local idle = m.ctx[Pack.SLOT.idle]
local names = {}
for i, a in ipairs(m.anims) do names[i] = a.name end
print((" anims: %s"):format(table.concat(names, " ")))
print((" idle slot -> %s, entrance -> %s, faint -> %s")
:format(tostring(idle), tostring(m.ctx[Pack.SLOT.entrance]),
tostring(m.ctx[Pack.SLOT.faint])))
local tracks = Pack.tracks(m, (idle or 0) + 1)
local animated = 0
for b = 1, m.boneCount do if tracks and tracks[b] then animated = animated + 1 end end
print((" idle: %d frames, %d/%d bones animated")
:format(m.anims[(idle or 0) + 1].frames, animated, m.boneCount))
-- the POSED extent, which is what actually gets drawn. Compare against
-- tools/stadium_pack.py's anim_sample, which was checked frame for
-- frame against the reference glTF export.
for _, fr in ipairs({ 0, 1, 5 }) do
local ah, af = boxAt((idle or 0) + 1, fr, true)
print((" idle frame %d: y %8.2f .. %8.2f"):format(fr, af, af + ah))
end
end
end
+383
View File
@@ -0,0 +1,383 @@
-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where
-- the two Pokemon are the Pokemon Stadium battle models rather than the
-- game's own pics stood up on their tiles.
--
-- What has to be looked at is a SPREAD OF SPECIES, because almost everything
-- that can go wrong here is per-model: how big it comes out (the size ladder
-- compresses a sixteenfold spread of authored heights -- see StadiumMon),
-- whether its feet are on the ground (the floor rule has to tell a hovering
-- Zubat from a Tentacruel centred on its own origin), whether the eyes
-- animate, and whether the species carries generated flame prims. So the
-- list below is deliberately awkward: the smallest and the largest in the
-- set, a hoverer, a floater, and the two species whose idle animation is
-- known to be wrong in the source data.
--
-- SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
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 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 Battles = lib.require("OverworldBattle")
local Stadium = lib.require("Stadium")
local Pack = lib.require("StadiumPack")
local Install = lib.require("StadiumInstall")
game.save.player.name = "RED"
-- foe, and the player's own, chosen to put two different problems in one
-- frame wherever possible
local CASES = {
{ "PIKACHU", "CHARIZARD" }, -- the reference pair; Charizard has flame
{ "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest
{ "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it
{ "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set
-- the five hermite-keyframe species (flags & 8), which the extractor
-- used to misread as packed streams: Exeggutor, Tangela and Magmar came
-- out so garbled the packer declined them to flat pics, and Pidgeot and
-- Dodrio animated garbled. All five must now stand as MODELS in a sane
-- standby loop (see StadiumFragment's animation notes).
{ "EXEGGUTOR", "MAGMAR" },
{ "PIDGEOT", "TANGELA" },
{ "DODRIO", "PIDGEOTTO" }, -- with its packed-stream evolution as control
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
}
-- ONTO THE MAP FIRST, before anything below waits on anything.
--
-- The models are built out of the player's own ROM on first run, on a
-- loading screen, and that screen is pushed from the mod's update hook on
-- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush).
-- A driver that waits for the build before it has got the player onto a map
-- waits forever: the condition that starts the build is the very thing the
-- waiting is postponing.
-- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose
-- whole claim is that it carries its stage: the shot has to be checked in a
-- CAVE and in a SHOP as well as on a route, both because those are the
-- places the map-staged rungs decline, and because the rung is supposed to
-- take that place's own sky and light with it.
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local MX = tonumber(os.getenv("DS_X") or "") or 5
local MY = tonumber(os.getenv("DS_Y") or "") or 8
U.teleport(game, MAP, MX, MY, "down")
-- Now wait it out, and say how long it took -- that number is what decides
-- whether the loading screen is acceptable rather than merely correct.
local waited, shotLoad = 0, false
while (Install.pending() or Install.status.state == "building")
and waited < 5400 do
if not shotLoad and Install.status.state == "building"
and (Install.status.done or 0) > 20 then
U.shot(game, ("%s/00_loading.png"):format(DIR))
shotLoad = true
end
U.wait(10)
waited = waited + 10
end
if waited > 0 then
U.log(("waited %d frames (%.1fs) for the model build -- %s")
:format(waited, waited / 60, tostring(Install.status.state)))
if Install.status.error then
U.log("build error: " .. tostring(Install.status.error))
end
end
U.log(("rom present: %s, packs ready: %s, stadium available: %s")
:format(tostring(Install.romPresent()), tostring(Install.ready()),
tostring(Install.available())))
-- the rung under test, and the back pic OFF: BACK SPRITES would keep the
-- player's own side on the menu, which is exactly the half of the shot
-- this driver exists to look at
local RUNGS = { cards = true, cardsb = "flatB", a = "stadium",
b = "stadiumB" }
local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium"
Battles.setting:setValue(rung, game)
Battles.backSetting:setValue(false, game)
-- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky,
-- the tint, the weight of the shadows -- so a driver left on CYCLE
-- photographs a different scene every run, and two runs of it cannot be
-- compared. DS_TOD overrides for a deliberate look at dusk or night.
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s")
:format(tostring(Battles.setting:get()), tostring(Stadium.enabled()),
tostring(Stadium.discs())))
-- did the models actually get built? one line rather than 151 silent
-- declines
local have, missing = 0, {}
for dex = 1, 151 do
if Pack.available(dex) then have = have + 1
elseif #missing < 6 then missing[#missing + 1] = dex end
end
U.log(("stadium packs present: %d/151%s"):format(
have, #missing > 0 and (" (missing " .. table.concat(missing, ",")
.. "...)") or ""))
-- let the neighbourhood's meshes land before the first fight, so the
-- opening frame is not the flat fallback
U.wait(90)
-- ------- DS_BLINK: how often does the eye actually change?
--
-- The texture animation rides the SKELETAL animation's frame and clamps
-- past the end of its own stream (StadiumRig.textures). Get either wrong
-- and a blink becomes a twitch: Rattata's standby loop is forty frames and
-- its blink is five, so wrapping on the blink's own length plays it six
-- times a second.
--
-- Counted rather than looked at, because "too fast" is a RATE and a
-- screenshot has no rate in it. This walks the idle loop frame by frame and
-- reports how many times the eye texture changes per second.
if os.getenv("DS_BLINK") then
local Pack = lib.require("StadiumPack")
local Rig = lib.require("StadiumRig")
for _, dex in ipairs({ 19, 4, 1, 25 }) do
local model = Pack.load(dex)
local rig = model and Rig.new(model)
if rig then
local idle = model.ctx[Pack.SLOT.idle]
local anim = (idle ~= Pack.NONE) and (idle + 1) or nil
local rec = anim and model.anims[anim]
-- which prim carries the eye, and what it shows each frame
local seen, changes, last = {}, 0, nil
local SECONDS = 4
for f = 0, SECONDS * Pack.FPS - 1 do
rig:pose(anim, f, true)
rig:textures(rec and rec.aux or nil)
local key = {}
for i, part in ipairs(rig.parts) do
if part.prim.texAnim and part.prim.texAnim >= 0 then
key[#key + 1] = i .. ":" .. tostring(part.texture)
end
end
key = table.concat(key, ",")
if last and key ~= last then changes = changes + 1 end
last = key
seen[key] = true
end
local looks = 0
for _ in pairs(seen) do looks = looks + 1 end
local period = rec and (rec.frames / Pack.FPS) or 0
U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- "
.. "%d texture changes in %ds, %d distinct looks; the idle "
.. "loop comes round every %.2fs, so that is %.1f changes a "
.. "cycle")
:format(dex, tostring(rec and rec.frames), period,
tostring(rec and rec.aux
and model.auxAnims[rec.aux].frames),
changes, SECONDS, looks, period,
period > 0 and changes / (SECONDS / period) or 0))
rig:release()
end
end
U.log("done -- " .. DIR)
return
end
-- ------- DS_FLY: does the Pokemon actually leave?
--
-- FLY and DIG take the user off the field for a turn, and the engine says
-- so through `picFx[battler].hidden` rather than through fxHidden (which is
-- the damage blink alone). A model that ignores that stands on its tile
-- while every attack aimed at it misses into empty air.
--
-- A TIMELINE again, not a still: what is being checked is that the model
-- goes when the pic goes and comes back when it comes back, which is three
-- moments and a screenshot has one.
if os.getenv("DS_FLY") then
local Stadium2 = lib.require("Stadium")
local move = os.getenv("DS_MOVE") or "FLY"
local mine = Pokemon.new(game.data, "CHARIZARD", 60)
-- give it the move outright: what is under test is the vanish, not
-- whether this species learns it by level 60
mine.moves = { { id = move, pp = 15 } }
game.save.party = { mine }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.tap(game, "a") -- FIGHT
U.wait(12)
U.tap(game, "a") -- the first (only) move
local goneAt, backAt, wasGone = nil, nil, false
for f = 1, 400 do
if f % 20 == 0 then U.tap(game, "a") end
U.wait(1)
local me = battle.player
local pf = battle.picFx and battle.picFx[me]
local picHidden = (pf and pf.hidden) and true or false
local showing = Stadium2.showing("player")
if f % 25 == 0 or (picHidden ~= wasGone) then
U.log((" f=%3d picHidden=%-5s model=%-5s anim=%s")
:format(f, tostring(picHidden), tostring(showing),
tostring(Stadium2.animOf("player"))))
end
if picHidden and not goneAt then goneAt = f end
if picHidden and not showing and not wasGone then
wasGone = true
U.shot(game, ("%s/fly_1_gone.png"):format(DIR))
end
if wasGone and not picHidden and not backAt then
backAt = f
U.shot(game, ("%s/fly_2_back.png"):format(DIR))
break
end
end
U.log(("%s: the pic hid at frame %s, the model was gone with it (%s), "
.. "and both came back at %s")
:format(move, tostring(goneAt), tostring(wasGone), tostring(backAt)))
U.log(wasGone and " OK -- the model leaves the field with the pic"
or " WRONG -- the model stood there while the game said it was gone")
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
U.log("done -- " .. DIR)
return
end
-- ------- DS_FAINT: does the collapse wait for the bar?
--
-- The faint animation must not start until the foe's HP bar has finished
-- emptying: `onFaint` fires the instant HP reaches zero, but the engine
-- queues the visible collapse behind the move animation and the drain, and
-- a Pokemon that lies down while its own health is still draining above it
-- reads as a bug (see Stadium's faintReady).
--
-- A TIMELINE rather than a screenshot, because what is being checked is an
-- ORDER: the frame the bar reaches zero against the frame the animation
-- changes. One printed line per frame settles that; two stills cannot.
if os.getenv("DS_FAINT") then
local Stadium2 = lib.require("Stadium")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
local barZeroAt, animAt, shot, stood = nil, nil, false, 0
for f = 1, 700 do
-- keep advancing the text, as a player would. Without this the queue
-- blocks on the damage message and the HP bar never drains at all --
-- which is a perfectly good demonstration that the collapse now waits,
-- and no demonstration whatever that it eventually arrives.
if f % 20 == 0 then U.tap(game, "a") end
U.wait(1)
local foe = battle.enemy
local shown = foe and foe.shownHP
local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil
if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then
U.log((" f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s")
:format(f, tostring(battle.phase), tostring(shown),
tostring(foe and foe.mon and foe.mon.hp),
tostring(foe and foe.faintQueued), tostring(anim)))
end
if shown and shown <= 0 and not barZeroAt then barZeroAt = f end
if anim == "faint" and not animAt then animAt = f end
-- a still from the middle of the drain: the foe must still be standing
if shown and shown > 0 and not shot and barZeroAt == nil
and foe and foe.faintQueued then
U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
shot = true
end
-- and how long the model then STAYS. The engine takes the flat pic off
-- after a fourteen-frame slide; the animation runs for one to eight
-- seconds, and being cut off mid-fall is worse than not falling at all.
if animAt and Stadium2.showing("enemy") then stood = f - animAt end
if animAt and f == animAt + 20 then
U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR))
end
if animAt and f == animAt + 90 then
U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR))
end
if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then
U.shot(game, ("%s/faint_4_gone.png"):format(DIR))
break
end
end
U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s")
:format(tostring(barZeroAt), tostring(animAt)))
if barZeroAt and animAt then
U.log((animAt >= barZeroAt)
and " OK -- the collapse waits for the bar"
or " WRONG -- the collapse ran while the bar was still draining")
end
U.log((" the model stayed on the field for %d frames (%.2fs) after the "
.. "collapse began; the engine's own pic slide is 14 (%.2fs)")
:format(stood, stood / 60, 14 / 60))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
U.log("done -- " .. DIR)
return
end
for i, case in ipairs(CASES) do
local foe, mine = case[1], case[2]
local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower())
game.save.party = { Pokemon.new(game.data, mine, 40) }
local battle = BattleState.newWild(game, foe, 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- The wipe, then the intro chatter, and -- the part that matters --
-- far enough past it that the player's own Pokemon has been SENT OUT.
-- Before that the player's side is legitimately the trainer's back
-- sprite, with no Pokemon on the field to model at all; a shot taken
-- there looks exactly like a bug and is not one.
U.wait(70)
-- tap until the FIGHT menu is up, which is the first moment BOTH
-- Pokemon are on the field in their standby loop
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
U.wait(45)
U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag))
-- FIGHT -> the first move, which is where the attack animation is
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(22)
U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag))
U.wait(45)
U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+96
View File
@@ -0,0 +1,96 @@
-- Scratch driver: the round tables of the Celadon diner and the Mart
-- roof terrace (the diner_round_table template's four placements).
-- Shot at the voxel rung (5) and the flat rung (3), front/back/side of
-- the diner table at cells (0,5):(1,6) plus the terrace pair.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/table_shots.lua \
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
-- AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR")
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
local TAG = os.getenv("AB_TAG") or "shot"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[table] DRAMATIC_SHAPE is not loaded")
return
end
local V = handle.lib
local Buildings = V.require("Buildings")
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- the diner table at (0,5):(1,6), seen from the south = its front
{ map = "CELADON_DINER", x = 1, y = 7, face = "up", label = "diner_front" },
-- the same table from the north = its back (standing between the two)
{ map = "CELADON_DINER", x = 0, y = 4, face = "down", label = "diner_back" },
-- edge-on from the east
{ map = "CELADON_DINER", x = 3, y = 5, face = "left", label = "diner_side" },
-- the roof terrace's table at (4,2):(5,3), from the south
{ map = "CELADON_MART_ROOF", x = 4, y = 4, face = "up", label = "roof_front" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
for _, rung in ipairs({ 5, 3 }) do
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
-- prove which models the RUNNING mod built for this map
local st = Buildings.stats()
local keys = {}
for k, v in pairs(st) do
keys[#keys + 1] = ("%s(v=%d)"):format(k, v.voxels)
end
table.sort(keys)
print(("[table] %s r%d models: %s"):format(s.label, rung,
#keys > 0 and table.concat(keys, " ") or "none"))
local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[table] capture missed: " .. path) end
end
else
print("[table] teleport failed: " .. s.map)
end
end
print(("[table] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end

Some files were not shown because too many files have changed in this diff Show More