mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-13 01:10:51 +02:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a1ab0266 | |||
| 2af88d07cf | |||
| 47369c24fe | |||
| 787d93a0e4 | |||
| c681167475 | |||
| 99de8c2760 | |||
| 34e7da5c12 | |||
| 9ddb940bc8 | |||
| c05c2cebd9 | |||
| d6a9f10352 | |||
| 4aeb0fc675 | |||
| 9be6888290 | |||
| 6482325e26 | |||
| b8b2e0cd33 | |||
| bee22507b2 | |||
| 1f9a720625 | |||
| 55145e3faf | |||
| 0091e6d93b | |||
| 120f9716b6 | |||
| 322defbbd0 | |||
| eb69bc7db8 | |||
| b05c7265d6 | |||
| 6b4dfe6b9b | |||
| de54e4ea26 | |||
| 0d245c4ccb | |||
| 2907aba6ff | |||
| c07aed2449 | |||
| a3fb18a589 | |||
| 66da6cbc4b | |||
| 121c87b629 | |||
| dfe2f1ae1a | |||
| 41f1f07342 | |||
| 848c9cb29f | |||
| 87a6c03017 | |||
| f0d5ca570a | |||
| d5542f9518 | |||
| 063ce6e328 | |||
| c9c5a8901a | |||
| d86d387243 | |||
| 89fe722a62 | |||
| 180dd393ea | |||
| 67bbb3d44b | |||
| 4ee15c4f32 | |||
| 5a4979303b | |||
| a46f194009 | |||
| 301bd4c1f9 | |||
| 55bc993ed7 | |||
| 6080531b08 | |||
| 53fff766a4 | |||
| 00f2b0bb9a | |||
| c8555e6820 | |||
| 91d9a37e8f | |||
| 20f9e19bf9 | |||
| 0d0ec51f51 |
@@ -44,3 +44,4 @@ model_extract/viewer.html
|
||||
# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with
|
||||
# tools/stadium_pack.py whenever it is wanted
|
||||
assets/stadium/
|
||||
pocket-voxel/
|
||||
+562
@@ -1,5 +1,567 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **SHINY POKEMON.** On by default, with no row to switch it off:
|
||||
shininess is a property of a Pokemon, not a display mode, and one that
|
||||
differed between two players' saves would be a setting rather than a
|
||||
Pokemon.
|
||||
|
||||
**It was always there.** Gen 1 has no shininess of its own, but it has
|
||||
the four DVs Gen 2 later reads to decide it, and the engine already
|
||||
ships that reading (`src/pokemon/Stats.lua`, `isShiny` -- its own
|
||||
comment calls it "the RBY virtual shiny"). So nothing new is stored on
|
||||
a Pokemon and nothing migrates: every save ever made already contains
|
||||
the answer, and this release starts drawing it. A mon is shiny when
|
||||
Defense, Speed and Special are all exactly 10 and Attack is one of
|
||||
2/3/6/7/10/11/14/15 -- which random DVs land on 1 time in 8192, the
|
||||
classic rate, and the default the odds dial ships at. Deriving rather
|
||||
than storing is what makes it survive a save, a box, a trade and an
|
||||
evolution without a second copy of the truth to drift out of step; a
|
||||
shiny Bulbasaur is a shiny Venusaur without being told. `mon.shiny` is
|
||||
maintained as a cache beside it, written from the DVs and never read
|
||||
as the source.
|
||||
|
||||
The roll happens in `Pokemon.new`, which is where every wild, gift,
|
||||
starter and traded mon is built -- before the battle bakes its sprite,
|
||||
which `battle.started` is already too late for. It draws from the mod's
|
||||
OWN random stream rather than the game's, so installing this does not
|
||||
shift the sequence every damage roll and encounter slot comes out of.
|
||||
Trainers' Pokemon come out ordinary by themselves, because the engine
|
||||
pins their DVs to a fixed set -- which is also what the real games do.
|
||||
|
||||
**The models are genuinely recoloured**, not tinted. The recolour runs
|
||||
as part of the Stadium extraction: each species' textures are decoded
|
||||
once, packed as usual, then recoloured and packed again beside it as
|
||||
`NNNs.dsm`. The colours are Stadium's own -- it slides a model in HSL
|
||||
rather than shipping second textures, a hue rotation in degrees plus
|
||||
saturation and lightness on a quantized -8..+8 scale at 12.5% a step --
|
||||
and all 151 sets of values are shipped in `data/shiny_colors.lua`.
|
||||
Five species get an explicit colour table instead, because Stadium
|
||||
gives THEM a real alternate texture and no single slide can reproduce
|
||||
it: Clefairy, Clefable, Jigglypuff, Wigglytuff and Gyarados, whose
|
||||
bodies must stay put while a small region rotates a long way.
|
||||
Generated effect frames -- flames, beams, sparks -- are excluded, so a
|
||||
shiny Charizard has a shiny hide and an ordinary fire.
|
||||
|
||||
Doing this at extraction rather than at load is what makes that
|
||||
exclusion exact: `StadiumFx` marks its generated frames and the packer
|
||||
drops the marker, so extraction is the last moment a flame is
|
||||
distinguishable from a hide. The normal packs come out byte-identical
|
||||
either way -- the shiny pass runs after they are written -- so
|
||||
`tests/stadium_extract_test.lua` still diffs all 151 against the Python
|
||||
oracle unchanged, and the format did not move. The install marker's REV
|
||||
goes to 3 so an existing cache rebuilds rather than quietly showing
|
||||
every shiny in its ordinary colours.
|
||||
|
||||
**Flat art is tinted** rather than recoloured, because the engine bakes
|
||||
a species palette into an image cache that has no idea which individual
|
||||
is being drawn. The tint is derived from that species' own shiny slide,
|
||||
so a shiny Golbat leans green and a shiny Charizard goes dusky. On the
|
||||
3D path each side is tinted separately, which is the only place the two
|
||||
sides can differ. A multiply can only darken, so species whose shiny is
|
||||
LIGHTER than their normal read quieter on the flat art than on the
|
||||
model.
|
||||
|
||||
**A sparkle** on arrival: a ring of additive stars that springs from
|
||||
the mon's chest and fades over three quarters of a second, armed on the
|
||||
frame a side's occupant changes -- which covers a send-out, a switch
|
||||
and a wild foe alike. A wild Pokemon never grows out of a ball, so the
|
||||
grow was the wrong edge to hang it on.
|
||||
|
||||
**A star on the status page**, beside the level on page 1, drawn in the
|
||||
engine's own pixel grid so it is palette-processed like every other
|
||||
pixel rather than floating over the finished frame. Page 1 only: page 2
|
||||
clears that block itself.
|
||||
|
||||
**SHINY ODDS**, on the DRAMATIC SHAPE menu itself rather than in one of
|
||||
its four categories -- those are the diorama, the fights, what the look
|
||||
costs and the headset, and an encounter rate is none of them. The row
|
||||
reads `1:8192` and halves down to `1:1`, so every rung is exactly twice
|
||||
as often as the one above it, and `1:8192` is both the default and the
|
||||
fallback for an unreadable options file: the mod's default is the games'
|
||||
own rate, not a buff. The number is the truth rather than an
|
||||
approximation of it, because a missed roll also clears a mon that landed
|
||||
on the pattern by luck -- without that, every setting would be itself
|
||||
and 1/8192 in parallel, and no setting could ever be rarer than 8192.
|
||||
|
||||
- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked
|
||||
through, so it opens on two opposite faces -- and the overworld drawing
|
||||
can only show one. The sprite is a facade seen face-on under a roof
|
||||
seen from above, so a SOUTH entrance is drawn (a doorway block in the
|
||||
facade's last rows, which `Structures` folds up into the front face)
|
||||
and a north, east or west one is drawn as nothing at all: the warp
|
||||
sits on the ground cell outside, the art beside it is plain wall.
|
||||
Top-down that reads fine, because the wall is never seen. In 3D you
|
||||
walked into a blank slab -- 33 of them, on eleven gates.
|
||||
|
||||
Each is now a real doorway, one cell of the tileset's own door art
|
||||
standing on the ground of the face you walk into, hung by the SAME
|
||||
rule the drawn facade hangs its own door by: the black frame stays
|
||||
flush with the wall and what it seals sinks a voxel behind it, so a
|
||||
side door and a front door are the same opening at the same depth, and
|
||||
the jambs the recess exposes fall out of the mesher wearing the
|
||||
frame's own texels. The art's outer ring is left alone -- a doorway
|
||||
cell is a cell OF a facade, its border is the wall beside and above
|
||||
the frame, and painting all 16x16 would stamp a one-pixel strip of
|
||||
front-wall art around every door. The same flood the facade tells wall
|
||||
from pane with, bounded to the cell, tells them apart here.
|
||||
|
||||
**Nothing is authored but the art.** `data/voxel_heights.lua` names
|
||||
one door cell per tileset and no coordinates: where the doors go is
|
||||
read off the map, from the warps that land in a gate house and the
|
||||
building standing against them. A warp is an entrance when it leads
|
||||
into a GATE-tileset map, is not already ON a door tile (a drawn south
|
||||
door would be fought over), and stands on a WALKABLE cell -- the ROM
|
||||
gives several gates an unreachable twin warp on the fence or tree
|
||||
beside the real opening, and a door behind a fence is a door into
|
||||
nothing. What is left is the entrance, and the two cells side by side
|
||||
that most gates do have come out as the double door they always were.
|
||||
So no hand list to drift out of step with a map edit, and every other
|
||||
building in the game is untouched: exactly the eleven gate placements
|
||||
get doors.
|
||||
|
||||
Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block
|
||||
is the gate on four routes and the warps sit at different rows of it
|
||||
on each -- so the model cache is keyed by the openings as well, and
|
||||
only placements that agree share a model. It costs about 290 quads a
|
||||
door (a flank quad carries one texel, so the art cannot merge into
|
||||
strips the way a facade's does) and no voxels: the recess removes as
|
||||
many faces as it exposes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Pokemon Center's steps climb.** The Cable Club stairs, cut into
|
||||
the back wall of all eleven Centers, were built as a stairwell sunk
|
||||
into the floor -- they lead UP to the Center's second floor. The
|
||||
head-on stair reading was right and stays: a drawn ROW is a step and
|
||||
drawn row is depth row, 1:1 into the opening, the drawing's own band
|
||||
table landing exactly on four steps and its black edge columns walling
|
||||
the opening. Only the sign of the rise was wrong, and two things follow
|
||||
from it. The risers turn around -- a flight descending away from you
|
||||
closes its steps from below and shows you their backs, one climbing
|
||||
away shows you their fronts -- and the black edge columns become the
|
||||
walls of the opening the flight climbs into, running from each tread up
|
||||
to the top of the wall band rather than down from the floor to it. The
|
||||
top step lands level with that band, in the dark rows the artist drew
|
||||
there, so the flight fills the opening it leaves by. The descending
|
||||
class (`stair_down_n`) is unchanged and still available; the Centers
|
||||
now pin `stair_n`.
|
||||
|
||||
- **Nothing is hung on the TOP of an interior wall.** A wall band is 16px
|
||||
of art folded upright over a run two drawn rows deep, so it folds
|
||||
entirely onto its south face and has no drawn row left to lay flat on
|
||||
top -- and the top then repeated the face. The town house's town-map
|
||||
poster (cell (3,0)) and its window ((5,0)), and the Pokemon Center's
|
||||
pokeball poster ((3,0) and (4,0)), each came out lying across the top
|
||||
of the wall as well as hanging on it: a picture you look DOWN on.
|
||||
|
||||
What is really up there is the wall's own capping course, which is
|
||||
exactly the plain panel the decorated column's neighbours draw --
|
||||
`wall_top` in `data/voxel_heights.lua` names it per tileset (HOUSE caps
|
||||
with the blank course, POKECENTER with the striped panel cell (9,0)
|
||||
draws). Per tileset rather than per tile because one room caps with one
|
||||
course, and because "plain" is a fact about the drawing that no
|
||||
measurement of the geometry can recover. Only the top face is
|
||||
redirected; the poster still faces the room.
|
||||
|
||||
Five rooms take it. `HOUSE`, `POKECENTER`, `REDS_HOUSE_1` and
|
||||
`REDS_HOUSE_2` name one course for every wall in the atlas -- each of
|
||||
those dresses one kind of room, and a list keyed by the decorated tiles
|
||||
would need extending every time a map hung something new on the same
|
||||
wall. `LOBBY` names the tiles instead (`{ [40] = 93, [56] = 93 }`): the
|
||||
Rocket lift's car doors cap with the cabin frame, and the department
|
||||
store, the Game Corner, Silph's floors and the roof -- all on that one
|
||||
atlas -- keep exactly the tops they had. The doors are also the reason
|
||||
the cap is applied in the mesher's DETECTED-run branch as well as its
|
||||
pinned one; Structures finds them rather than a pin naming them.
|
||||
|
||||
- **Lance's room is furnished with the badge gyms' bird statue.** It is
|
||||
the gyms' statue tile for tile on the DOJO atlas -- one cell of figure
|
||||
($02/$38/$12/$13) over one cell of plinth ($22/$23/$32/$33) -- and left
|
||||
derived the pair merged into one 32px volume wearing the statue folded
|
||||
onto its face. The extruded picture, the same failure the gyms' statues
|
||||
and the Plateau's avenue had, and it takes the same answer: the plinth a
|
||||
solid 16px block, the bird a per-pixel cutout 5 voxels deep riding its
|
||||
top face. Every placement of those eight tiles in the game is a statue
|
||||
-- 18 in LANCES_ROOM and 2 in FIGHTING_DOJO -- and Oak's Lab, the third
|
||||
map on the atlas, places none of them.
|
||||
|
||||
- **A wall cut into a terrace inherits terrace, never the statue standing
|
||||
on it.** `bookcase_backfill = "above"` hands a collapsed rank's vacated
|
||||
rows the cell above the run, so the League's gate walls have more
|
||||
hillside behind them rather than a trench. Indigo Plateau's avenue
|
||||
statues stand directly on the pilasters that collapse that way, so what
|
||||
every one of them inherited was the BIRD: the figure's shape and art
|
||||
copied onto two more rows down the shaft, and each statue came out two
|
||||
deep behind itself. Only bodies backfill now -- flat, top and upright.
|
||||
A per-pixel standee above (a statue, a sign, a bush) is an object
|
||||
standing ON the terrace rather than terrace, so the row has nothing to
|
||||
inherit and takes the default synthesized ground.
|
||||
|
||||
- **...and a statue on a collapsed pilaster stands ON it.** The duplicate
|
||||
above was masking a second fault. A standee finds its support by reading
|
||||
the cell below its own drawing, and the bookcase collapse MOVES the box
|
||||
it finds: the whole four-row pilaster walks onto its southmost cell,
|
||||
which on the Plateau is a full cell south of where the test looked. So
|
||||
the bird was lifted to the right HEIGHT and left standing over open
|
||||
ground with its pillar behind it -- invisible while the vacated rows
|
||||
were being filled with copies of the bird itself, obvious the moment
|
||||
they were not. Every row of a collapsed rank now records the row its box
|
||||
actually stands on (`S.bookcaseBox`), and a standee supported by one is
|
||||
placed there instead of at its drawn position. Supports that do not move
|
||||
-- the gyms' plinths, furniture, `building` claims -- are unaffected.
|
||||
The plinth keeps its elevation and the statue extends exactly one cell
|
||||
above it.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
### Added
|
||||
|
||||
- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new
|
||||
three-rung row. CATCH ONLY changes nothing about the game except the
|
||||
throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the
|
||||
BALL row of the safari menu) opens capture mode instead of the automatic
|
||||
toss. FULL makes wild encounters the real Let's Go article: the
|
||||
encounter IS the catch -- it opens in throwing mode and stays there,
|
||||
the foe never takes a turn, your own Pokemon is never sent out or
|
||||
shown (no back pic, no model, no HUD), and B runs, which from a catch
|
||||
encounter always works. Poke/Great/Ultra Balls are half price at every
|
||||
mart, and EXPERIENCE works the way that game's does: every healthy
|
||||
party member gains from every catch AND every trainer knockout, each
|
||||
one measured against its OWN level through the Gen VII scaled formula
|
||||
-- which is why Let's Go ships no EXP.ALL, and why a level 5 party
|
||||
member takes several times what a level 45 one does from the very same
|
||||
fight. A catch adds the throw stack on top: grade, first ball of the
|
||||
encounter, new species, and a persistent catch combo. CATCH ONLY
|
||||
leaves experience exactly as the original game had it.
|
||||
|
||||
**The throw.** The camera locks HEAD ON with the wild Pokemon -- its
|
||||
own seat on the arena's axis, no drift, no steer -- and a real 3D Poke
|
||||
Ball (modelled and animated for this: hinged lid, capture beam,
|
||||
squash-click, decaying wobble, caught stars, breakout burst; GREAT
|
||||
blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the
|
||||
bottom of the frame. The ball rides UNDER the finger -- mouse, touch,
|
||||
or the right stick -- and releasing throws it with the swipe's own
|
||||
velocity: forward from how hard, height from its rise, side from its
|
||||
slant, gravity and collision deciding the rest, with a bearing-and-
|
||||
range assist trimming honest errors. Circling the ball WINDS it -- the
|
||||
spin visibly builds with the gesture to a cap and bleeds off when the
|
||||
hand pauses -- and only a ball at the cap flies with the late-biting
|
||||
curve. Contact is against the creature's own GEOMETRY: a pic foe is
|
||||
its sprite's opaque pixels (a ball through the gap under a wing flies
|
||||
on), a STADIUM foe its model's measured height, girth and hover. The
|
||||
timing ring pulses on the creature, coloured by the Gen 1 odds, and is
|
||||
judged AT the moment of contact: inside earns NICE / GREAT /
|
||||
EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
|
||||
shakes the roll answers are the rocks the ball plays on the ground.
|
||||
|
||||
**Running out, and staying out of the way.** Under FULL an empty bag
|
||||
does not hand the fight back to the classic menu -- there is no fight to
|
||||
hand back, since a Let's Go wild has no Pokemon of yours in it and a foe
|
||||
that never takes a turn, so that menu would offer a FIGHT that cannot
|
||||
happen. The encounter keeps its own screen: the seat holds, the Pokemon
|
||||
stands there, the readout says NO BALLS LEFT, and RUN is the way out.
|
||||
Throwing your last ball lands in the same place rather than ending the
|
||||
session. And the scripted catch tutorials -- the VIRIDIAN CITY old man,
|
||||
and Yellow's PROF.OAK catching the PIKACHU -- are left alone at every
|
||||
rung: they are cutscenes wearing a battle's clothes, where the cursor,
|
||||
the bag and the throw are all scripted and nobody keeps the Pokemon, so
|
||||
they play exactly as the original does with no capture screen, no held
|
||||
camera and no experience.
|
||||
|
||||
**What it stands on.** The outcome is exactly a Gen 1 ball throw: same
|
||||
catch math (status, HP and ball factors intact), same outcome texts,
|
||||
same caught flow -- dex page, nickname, box overflow -- and a missed
|
||||
ball is a spent ball. Outside FULL, a failed throw still costs the
|
||||
turn it always did. Needs the staged 3D battle standing (3D-BTL on, a
|
||||
depth-capable driver, no headset); anywhere it cannot stand, balls
|
||||
quietly take the engine's classic toss.
|
||||
|
||||
- **SHADOWS: a row that stands the sun's pass down.** Cast shadows are the
|
||||
most expensive thing the mode draws after the geometry -- the whole world
|
||||
rendered a second time from the light, every time the view or anybody in
|
||||
it moves -- and on a phone or an old laptop that is the difference between
|
||||
the diorama running and the diorama stuttering. ON by default, because a
|
||||
world where a building throws nothing reads as flat however many voxels it
|
||||
is made of. OFF means off rather than "fall back": the flat decal drop
|
||||
shadows are the stand-in for a machine that WANTED shadows and could not
|
||||
have them, so they stay down too, and the forest's light shafts go with
|
||||
them (the beams are lit by the sun's own map). FULL neither sets the row
|
||||
nor takes it away, on the same reasoning as AA -- what the look costs is
|
||||
the player's question, not a preset's.
|
||||
|
||||
### Added
|
||||
|
||||
- **SELECT on any row of the mod's menus explains what it does.** Every
|
||||
setting here has carried a paragraph of help since it was written -- it is
|
||||
handed to the mod manager with the rest of the schema -- and nothing in the
|
||||
engine has ever drawn one. It could not: a row is a label and a value, and
|
||||
no options row anywhere has room for a third thing. So a row says what it
|
||||
IS on one line and what it is SET TO on the next, and SELECT says what that
|
||||
MEANS, which is the question RENDER DIST or 2D-3D B cannot answer in
|
||||
eighteen characters however the label is worded.
|
||||
|
||||
It opens the game's own dialogue box -- drawn with the ROM's own border
|
||||
glyphs, anchored to the bottom of the screen where this game has always put
|
||||
text, and only as tall as the sentence it holds, so the row being asked
|
||||
about is still visible above it. A, B, START and SELECT all close it, SELECT
|
||||
included: it is the button somebody who just pressed it will reach for. The
|
||||
bottom line of every one of the mod's menus now reads `B BACK SEL HELP`,
|
||||
because a binding nobody knows about is worth nothing.
|
||||
|
||||
Every description is ONE SENTENCE, and the whole of it is on screen at once.
|
||||
The long paragraphs these grew from were written for a reader that never
|
||||
existed, and they read as documentation rather than as an answer; a box you
|
||||
have to scroll is a worse reply to "what does this do" than a shorter
|
||||
sentence is. Both properties are tested rather than trusted -- a description
|
||||
that gains a second sentence, or that outgrows its box, fails the suite.
|
||||
VOXEL, T-SHIFT and STADIUM ROM got sentences of their own to go with the
|
||||
thirteen settings: the first two are the engine's row descriptors with
|
||||
nowhere to keep one, and the third is an action rather than a setting.
|
||||
|
||||
The suite also checks every character of every description against the ROM's
|
||||
real charmap, because Font.encode answers a glyph it does not have with a
|
||||
SPACE and a one-time console warning -- so a curly quote pasted in from
|
||||
somewhere would blank a word on screen and say nothing about it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The settings live on menus of their own now, behind one red row at the
|
||||
top of OPTIONS.** This mod had grown to fourteen rows on the engine's
|
||||
list, spliced in as one block. OPTIONS shows four boxes at a time, so that
|
||||
was four screens of scrolling inside a list that already carried twenty
|
||||
engine rows, and finding SHADOWS meant knowing it was in there somewhere
|
||||
past the wireframe and the horizon bend.
|
||||
|
||||
What is on OPTIONS now is `DRAMATIC SHAPE..`, and it leads the list --
|
||||
a mod that replaces the look of the whole game should not make the player
|
||||
scroll to find out where its settings went, least of all past the engine
|
||||
rows it has quietly taken away. It opens VOXEL and T-SHIFT, which came off
|
||||
the engine's list with it, and four categories: **3D WORLD** (V-GRID,
|
||||
V-CURVE, RENDER DIST, WATER, DAYTIME), **BATTLES** (3D-BTL, BACK SPRITES,
|
||||
LET'S GO), **PERFORMANCE** (FOREST FX, SHADOWS, AA) and **VR** (VR,
|
||||
SMOOTH TURN). STADIUM ROM stays on that top-level menu, last: it is
|
||||
one-time setup rather than a setting, and somebody who has been told to
|
||||
import a cartridge should find the row where the mod begins, not two
|
||||
levels down a category they have no reason to open until it has worked.
|
||||
|
||||
The split is not a new opinion: it is the `full` flag each row already
|
||||
carried. `full` marks a row the FULL preset does not take away, and the
|
||||
reason written beside each one was always the same -- this is a question
|
||||
about the HARDWARE, or about the GAME, not a knob on the diorama FULL is a
|
||||
preset for. So 3D WORLD is exactly the rows FULL owns, and needs no rule
|
||||
to disappear under it: every child filters itself out and an empty category
|
||||
is not offered. Under FULL the menu is four rows on one screen with no
|
||||
scroll arrow. The same rule retires VR where there is no VR to have.
|
||||
|
||||
**Nothing you had set has moved.** Every setting keeps its stored key, its
|
||||
ladder and its row id, so `options.lua` is byte-identical across the
|
||||
upgrade for a player who changes nothing -- and the hotkeys are untouched,
|
||||
which is what makes the nesting affordable: 3, 5, 6, 7, 8 and 9 still put
|
||||
every buried row one keypress away. The mod manager's own page still lists
|
||||
all thirteen settings flat, now in category order.
|
||||
|
||||
The row is drawn in red, which is a palette zone rather than a color:
|
||||
`setColor` cannot tint this text, because the glyph atlas is black ink and
|
||||
LOVE tints multiplicatively, and because the palette shader keys on the red
|
||||
channel alone and would send a red pixel to the lightest slot. What the
|
||||
zone changes is which color the shade the text was drawn in comes out as.
|
||||
It is MEWMON -- the palette the OPTIONS menu already wears -- copied with
|
||||
only the ink slot replaced, so the paper under the row is the same white as
|
||||
the row above it in all three ROMs, and the band covers the two text lines
|
||||
alone rather than the cursor and the box borders beside them. SGB INV
|
||||
reverses a palette, so there the red starts in the other slot and still
|
||||
lands on the ink; OG, OG INV and CLASSIC substitute their own tables
|
||||
outright, and the row simply draws monochrome, which is what asking for a
|
||||
screen with no colors in it should get.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A setting that pins another one now pins it from wherever it was
|
||||
changed.** 3D-BTL holds BATTLE LAYOUT at OG while a fight can be staged on
|
||||
the map, and FULL holds DAYTIME at SYNC while it owns that row. Both pins
|
||||
used to be a side effect of the options-rows hook, which every step on the
|
||||
OPTIONS menu happened to rerun -- so they fired whether or not the step was
|
||||
the one that mattered, and nothing had to name them. A step made on the
|
||||
mod's own menus reruns no hook, so the pinning is a function now, and the
|
||||
hook, the menus and the mod manager's page all ask for it.
|
||||
|
||||
- **An open OPTIONS menu notices a change made on a menu pushed over it.**
|
||||
The rebuild that keeps the row list honest compared the voxel level and the
|
||||
two battle switches across one call of `update`. The stack ticks its top
|
||||
state only, so a step taken on one of the mod's own menus happens while
|
||||
OPTIONS is suspended: both halves of that comparison were read after the
|
||||
fact and always agreed, and OPTIONS came back still showing a BATTLE LAYOUT
|
||||
row that no longer belonged there. The signature is held on the menu and
|
||||
stamped where the rows are built, which is the thing it is a signature of.
|
||||
|
||||
- **A building's back no longer wears its own front door.** Every voxelized
|
||||
building is its drawing extruded straight through the footprint, so the
|
||||
far wall is the facade again -- and read from behind, the facade mirrored:
|
||||
a door on the back of every house, a POKe sign readable backwards on
|
||||
every Center, MART on every mart and GYM painted across the back of every
|
||||
gym. Those tiles are now named per tileset (`frontOnly` in
|
||||
`data/voxel_heights.lua` -- the doorways, the hanging shop signs and the
|
||||
gyms' lettering) and every cell wearing one takes the art of the nearest
|
||||
ordinary cell beside it in the same tile row instead. The donor is picked
|
||||
per RUN, so a two-tile doorway comes out as two tiles of the same wall
|
||||
rather than borrowing left from one side and right from the other, and
|
||||
between the two neighbours the one that row uses more often wins -- which
|
||||
is what reaches past a gable's sloped corner for the wall behind it. At
|
||||
the base course the donor lifts one row with the model, because the
|
||||
drawing's last row is the black threshold a door stands on and the wall
|
||||
beside it does not paint; without that the doorway kept its own foot and
|
||||
the back's bottom course had a notch in it. Windows are deliberately left
|
||||
alone: a back wall with windows is right. The generic volume path folds
|
||||
the same drawing up all four sides and had the same bug on its back AND
|
||||
its flanks, so it takes the same substitution -- only the south face,
|
||||
which IS the drawing, keeps every tile of it.
|
||||
|
||||
- **B now actually runs from capture mode -- and A throws, and L/R switch
|
||||
balls.** The capture session read its button presses on the RENDER clock,
|
||||
along with everything else it does per frame. Button edges do not survive
|
||||
there: the engine rebuilds the edge table once per fixed logic step and
|
||||
runs all of a frame's steps BEFORE the render-clock hooks, so any frame
|
||||
carrying more than one step had already thrown the press away before
|
||||
anything looked at it. That is not a rare race -- it is every press below
|
||||
60fps, which is exactly where a 3D battle lives, so these buttons were
|
||||
reliably dead on the machines that most needed them and fine on a 144Hz
|
||||
one. They are read on the logic step now, through the engine's own
|
||||
input.step seam, and taken rather than peeked so a press the capture used
|
||||
does not also page the message it just queued.
|
||||
|
||||
- **No more grass smeared across the top of a LET'S GO throw.** The capture
|
||||
seat handed BattleScene its pitch as the DEPRESSION below level, and the
|
||||
one thing that reads it -- the camera-ward pull the grass and flowers are
|
||||
drawn with -- measures angles off STRAIGHT DOWN, the complement. So a seat
|
||||
looking nearly level read as the top-down end of the ladder, where the pull
|
||||
is longest: 46 world pixels of bias, handed to a camera standing 46 world
|
||||
pixels behind the player. The pull is a shove along each vertex's own eye
|
||||
ray -- a pure depth bias while it is shorter than the range, and past that
|
||||
it carries geometry THROUGH the lens, where the projection turns inside out
|
||||
and a single tuft at the eye lands smeared across the frame. That was the
|
||||
greenery hanging over the top of a capture shot on any route or street with
|
||||
grass rows beside it. The seat now speaks the same convention the battle's
|
||||
own rig does, and the vertex stage clamps the pull to half the range to the
|
||||
eye besides -- so no camera standing this close can be smeared by a bias
|
||||
again, in a capture, a fight, or first person.
|
||||
|
||||
- **The grass moves during a staged battle.** The wind is switched on around
|
||||
the free-roam pass's grass draws and off again after them, and the battle
|
||||
pass -- which draws the same tufts, on the same map, from its own camera --
|
||||
never switched it on: the uniform sat at the per-frame default, which means
|
||||
no wind, so a field that was moving one frame before the encounter went dead
|
||||
still for the whole fight and started again when it ended. A fight is staged
|
||||
on the MAP, in that place's own weather and light; a frozen field was the one
|
||||
thing reading as a photograph of it rather than the place. No walker-contact
|
||||
push comes with it -- that is somebody stepping through the grass, and the
|
||||
two mons stand still on their own tiles.
|
||||
|
||||
- **The bottom of the frame no longer bites a row out of the scenery.**
|
||||
RENDER DIST cut the world to where the frame's rays land on the GROUND,
|
||||
and the ground is not what the picture is made of: a tree at the bottom of
|
||||
the screen has its feet south of the row its top is seen on, because the
|
||||
bottom edge's ray is still coming down as it passes them. The cut is by
|
||||
column -- deliberately, so it never takes the tops off trees -- so a tree
|
||||
whose base fell one pixel outside lost its whole height at once, and the
|
||||
last row of forest along the bottom of the frame was cut through with the
|
||||
ground behind it showing. The south edge is now walked back down that same
|
||||
ray by the tallest thing that can stand on it (about a tile and a half at
|
||||
35 degrees, four tiles at 50, eleven at 75), plus a tile of slack so a hard
|
||||
edge is never decided by a rounding. FIT carries it too: it is a correction
|
||||
to the honest answer, not margin around it.
|
||||
|
||||
## 1.7.1
|
||||
|
||||
### Added
|
||||
|
||||
- **RENDER DIST: stop drawing the map you cannot see.** The orbit rungs now
|
||||
cut the world to the ground the camera actually frames, so a connected
|
||||
map that falls entirely outside it is skipped before it is drawn --
|
||||
terrain, water, grass, flowers and its whole shadow pass. At the high
|
||||
rungs, where the camera is nearly overhead, that is most of the frame's
|
||||
geometry never submitted.
|
||||
|
||||
**The footprint is not the window.** Tilt the camera and the ground it
|
||||
frames stops being the flat game's own rectangle and becomes a
|
||||
trapezoid: reaching much further north, flaring much wider out there,
|
||||
and pulling in at the near edge. At 35 degrees a 320x288 view reaches
|
||||
270 world pixels north where the window reaches 144, and 246 to each
|
||||
side where the window reaches 160 -- so a window-sized cut takes a bite
|
||||
out of a world plainly on screen, with sky showing through the top and
|
||||
both sides. It is derived from the orbit's own basis rather than guessed
|
||||
-- the frame's corner rays dropped on the ground plane, in closed form,
|
||||
cross-checked against a ray cast in the suite -- and the stored
|
||||
rectangle sits north of the view centre, because the trapezoid does.
|
||||
|
||||
**The row is a real render distance at 75.** Past about 63 degrees
|
||||
(exactly `atan(2*FOCAL)`) the horizon is inside the frame and "all the
|
||||
ground on screen" is an infinite answer, so something has to name a
|
||||
distance. FIT is the closest of the four, WIDE through WIDEST push the
|
||||
world's edge out, OFF stops cutting. Below that pitch the honest
|
||||
footprint is already inside the reach and the row does nothing to the
|
||||
picture at all.
|
||||
|
||||
The cut reaches the shader as the same box the headset's DIORAMA uses --
|
||||
rectangular now, with two half-extents, and the diorama passes the same
|
||||
number twice. Under V-CURVE the rim dissolves rather than cutting,
|
||||
because a bent world has no straight sides. The sun and the eye ask the
|
||||
same question about the same maps, so the light can never record a map
|
||||
the camera did not draw.
|
||||
|
||||
Not on 1ST or 3RD -- the player is standing in the world there -- and
|
||||
the box opens out and away over the rung tween rather than vanishing on
|
||||
the frame the rung changed. FULL sets it to FIT.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Added
|
||||
|
||||
- **The DIORAMA modes: Kanto as a model you can pick up.** The VR row is a
|
||||
ladder now -- OFF / STANDARD / DIORAMA / DIORAMA-MR. STANDARD is what the
|
||||
mod already did (the headset follows the VOXEL ladder, orbit rungs a
|
||||
tabletop and 1ST life size). DIORAMA is one presentation instead of a
|
||||
ladder: the world is always the model on the table, and the model is a
|
||||
thing in the room.
|
||||
|
||||
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, cut with
|
||||
a hard edge because a flat world is a thing with sides. The cut reaches
|
||||
every pass the world is made of -- terrain, characters, grass, water and
|
||||
the forest's beams and motes.
|
||||
|
||||
**V-CURVE 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 that is a lie about what is being looked at. So the
|
||||
box becomes a **ball**, and its edge becomes a **gradient** dissolving
|
||||
into the sky (the same sky the flat screen has). One click of the left
|
||||
stick throws the row and swaps the whole reading of the model.
|
||||
|
||||
**A staged fight** ignores both shapes and cuts a vertical **pillar**
|
||||
about the arena, always dissolved at the rim, framing the model to it:
|
||||
the fight lifted out of the map as a floating disc.
|
||||
|
||||
**The grips** take hold of the whole thing: 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 for as long as the mode runs, and the Pokedex stays away.
|
||||
|
||||
**DIORAMA-MR** is the same mode with the background keyed pure green --
|
||||
no bands, no sun, no haze, because every one of those is a colour a
|
||||
keyer would have to survive -- for a mixed-reality capture that
|
||||
composites the model into the player's own room.
|
||||
|
||||
A save that stored the old VR toggle as `true` comes back on STANDARD
|
||||
rather than falling to OFF. The viewport is compiled into the scene
|
||||
shader as its own variant, so a flat frame -- and a phone above all --
|
||||
builds and binds exactly what it always did.
|
||||
|
||||
## 1.6.2
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,10 +17,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 |
|
||||
| `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 |
|
||||
|
||||
@@ -185,13 +187,43 @@ 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
|
||||
|
||||
@@ -204,10 +236,11 @@ 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 |
|
||||
|
||||
@@ -261,4 +294,4 @@ 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).
|
||||
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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+531
-35
@@ -50,6 +50,9 @@
|
||||
-- stair_down_e / _down_w a sunken stairwell: the cell opens into a
|
||||
-- hole with steps descending toward the named
|
||||
-- side -- stairs that lead DOWN a floor
|
||||
-- stair_n / stair_down_n the same two flights running into the map
|
||||
-- rather than across it, for a staircase drawn
|
||||
-- HEAD-ON: a drawn row is a step, not a column
|
||||
-- relief a prop drawn from above (a console on the
|
||||
-- floor): the drawing stays flat and the
|
||||
-- pixels inside its black outline extrude a
|
||||
@@ -129,6 +132,24 @@
|
||||
-- measured rather than drawn, by flooding the panel tile's own stripe out
|
||||
-- from behind them; see CLUB below.
|
||||
--
|
||||
-- And `wall_top` (not a class either): what a `wall` cell's TOP face
|
||||
-- wears, whatever the cell itself draws. An interior wall band is 16px of
|
||||
-- art folded upright, and a fully folded run has no drawn row left over to
|
||||
-- lay flat on top -- so the top repeated the FACE, and the town-map poster
|
||||
-- and the window of a house came out lying across the top of the wall as
|
||||
-- well as hanging on it. What is really up there is the wall's own
|
||||
-- capping course, which is exactly the plain panel the decorated column's
|
||||
-- neighbours draw. The face is untouched -- the poster still faces the
|
||||
-- room. Two forms:
|
||||
--
|
||||
-- wall_top = <id> every wall in the tileset caps with this,
|
||||
-- for an atlas that dresses one kind of room
|
||||
-- (the houses, the Centers, Red's floors)
|
||||
-- wall_top = { [tile] = id } only the named tiles are redirected, for
|
||||
-- an atlas that dresses several (LOBBY is
|
||||
-- the department store, the Game Corner,
|
||||
-- Silph, the roof AND the Rocket lift)
|
||||
--
|
||||
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
|
||||
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
|
||||
-- and no single class covers that. They live in the `buildings` list at
|
||||
@@ -168,6 +189,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 +222,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 +265,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 +386,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 +478,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 +754,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
|
||||
@@ -702,12 +779,38 @@ return {
|
||||
signpost = { 33, 34, 49, 50 },
|
||||
},
|
||||
|
||||
-- Oak's Lab (the tileset also serves the Fighting Dojo and Lance's
|
||||
-- room, which use none of these tiles). The free-standing shelf
|
||||
-- ranks: book rows and base pinned; the shared trim tiles above
|
||||
-- (41/42, also the lab tables' corners) are adopted as caps by the
|
||||
-- bookcase builder rather than pinned.
|
||||
-- Oak's Lab, the Fighting Dojo and Lance's room (one tileset). The
|
||||
-- lab's free-standing shelf ranks: book rows and base pinned; the
|
||||
-- shared trim tiles above (41/42, also the lab tables' corners) are
|
||||
-- adopted as caps by the bookcase builder rather than pinned. The
|
||||
-- other two rooms are furnished with one thing between them -- the
|
||||
-- BIRD STATUE, and it is the badge gyms' statue exactly.
|
||||
DOJO = {
|
||||
-- THE STATUES. The same drawing as GYM's, tile for tile on this
|
||||
-- atlas: one cell of figure ($02/$38/$12/$13) over one cell of
|
||||
-- plinth ($22/$23/$32/$33), so it takes the same treatment -- the
|
||||
-- plinth a SOLID 16px block, the figure a per-pixel cutout 5 voxels
|
||||
-- deep (the thin `prop` pool) riding the plinth's top face through
|
||||
-- the authored-box support rule and collapsing to the plinth's
|
||||
-- single cell of footprint.
|
||||
--
|
||||
-- Left derived the pair merged into ONE 32px volume wearing the
|
||||
-- statue art folded onto its face -- the extruded picture, the same
|
||||
-- failure the gyms' statues and the Plateau's avenue had.
|
||||
--
|
||||
-- Every placement of these eight tiles in the game is a statue:
|
||||
-- blocks 49/50/114/115 pack figure over plinth in one 2x2-cell
|
||||
-- block, and they are placed 18 times in LANCES_ROOM (the pairs
|
||||
-- lining his aisle, and the two flanking his dais at cells (6,12)
|
||||
-- and (7,12) over (6,13)/(7,13)) and twice in FIGHTING_DOJO. Oak's
|
||||
-- Lab, the third map on this atlas, places none of them -- and the
|
||||
-- four figure-only and plinth-only blocks are never placed at all.
|
||||
wall = { 34, 35, 50, 51 },
|
||||
prop = { 2, 18, 19, 56 },
|
||||
-- and each stands on the room's main floor ($11) rather than on
|
||||
-- whatever its neighbours vote -- the gyms' rule, for the gyms'
|
||||
-- reason: a statue against a wall would otherwise take the wall.
|
||||
prop_ground = { [2] = 17, [18] = 17, [19] = 17, [56] = 17 },
|
||||
bookcase = { 13, 14, 29, 30 },
|
||||
-- the lab tables (the starter-ball display and the north tables):
|
||||
-- 41/42 are also the shelf trim the bookcase builder adopts as
|
||||
@@ -731,6 +834,11 @@ return {
|
||||
REDS_HOUSE_2 = {
|
||||
-- the wall band with its windows stays one 16px face
|
||||
wall = { 0, 36, 37, 52, 53 },
|
||||
-- and caps with the blank course. The windows of Red's 2F sit at
|
||||
-- cells (5,0) and (7,0); without this the panes came out lying
|
||||
-- across the top of the wall as well as glazing its face, where
|
||||
-- (6,0) between them draws the plain panel that belongs up there
|
||||
wall_top = 0,
|
||||
-- the bed: a mattress drawn from above, half a block high
|
||||
bed = { 45, 46, 47, 61, 62, 63 },
|
||||
-- stools: a seat-high box, seat art on top, legs on the front
|
||||
@@ -769,6 +877,9 @@ return {
|
||||
-- table rides these heights, not the 8/12px class defaults
|
||||
heights = { stool = 5, table = 6 },
|
||||
wall = { 0, 36, 37, 52, 53 },
|
||||
-- the same blank course caps 1F, whose windows are cells (3,0),
|
||||
-- (5,0) and (7,0) -- (6,0) between the last two is the panel
|
||||
wall_top = 0,
|
||||
stool = { 2, 3, 18, 19 },
|
||||
-- the dining table (38-44/58-60); its top row also caps the
|
||||
-- bookcases below
|
||||
@@ -804,6 +915,12 @@ return {
|
||||
-- the wall band stays one 16px face: blank courses, the window,
|
||||
-- the framed picture, and the schoolhouse blackboard (72-75/88-91)
|
||||
wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 },
|
||||
-- and it caps with the blank course. Cells (3,0) and (5,0) of the
|
||||
-- town house are the town-map poster (45/46 over 61/62) and the
|
||||
-- window (36 over 52); without this the top of the wall wore the
|
||||
-- poster and the glass, laid flat, instead of the plain panel their
|
||||
-- own left-hand neighbour draws
|
||||
wall_top = 0,
|
||||
-- stools: a seat-high box that also seats Daisy
|
||||
stool = { 2, 3, 18, 19 },
|
||||
-- the dining table (top edge 38/41 also caps the bookcases);
|
||||
@@ -834,9 +951,8 @@ return {
|
||||
-- which has a PERSON drawn into the tile art -- becomes a monolith
|
||||
-- wearing his face.
|
||||
POKECENTER = {
|
||||
-- the wall band stays one 16px face: striped panels (40), the high
|
||||
-- windows (92-95; 94 doubles as the map's warp tile, and pins are
|
||||
-- look-only), the pokeball poster (2/3/18/19), and the pillars
|
||||
-- the wall band stays one 16px face: striped panels (40), the
|
||||
-- pokeball poster (2/3/18/19), and the pillars
|
||||
-- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
|
||||
-- trap and would recess into a pond lip). The healing machines'
|
||||
-- console face (76/77) and button panel (6/22) are ALSO wall:
|
||||
@@ -849,12 +965,44 @@ return {
|
||||
-- void rule flattens them. What is NOT wall is the machines'
|
||||
-- two flanks -- see `prop` below.
|
||||
wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
|
||||
76, 77, 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) with the nurse's
|
||||
-- tray (10), front face (24/25, the game's counterTiles), left end
|
||||
-- cap (56) and the Cable Club's light sections (90/91). 8px is
|
||||
-- one clean band, so the drawn front panel stands up and the
|
||||
-- counter top stays on top; at 12 they read as wall stubs
|
||||
76, 77 },
|
||||
-- and it caps with the striped panel, the tile cell (9,0) draws.
|
||||
-- The pokeball poster spans cells (3,0) and (4,0) (2/3 over 18/19),
|
||||
-- and without this the top of the wall wore the poster lying flat as
|
||||
-- well as hanging it on the face
|
||||
wall_top = 40,
|
||||
-- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and
|
||||
-- (12,0) of every Center -- a flight going UP, away from the room, to
|
||||
-- the Center's second floor, and the reason the head-on stair classes
|
||||
-- exist at all: the profile's other stairs run east or west and are
|
||||
-- drawn from the SIDE, where a drawn column is a step; these are drawn
|
||||
-- HEAD-ON, where a drawn row is, and no rotation of the east/west
|
||||
-- reading produces that.
|
||||
--
|
||||
-- The drawing is its own band table, and it lands exactly on an even
|
||||
-- four-step division of the cell: 4 white rows (the near tread, the
|
||||
-- one at floor level), a black nosing, 3 grey, a nosing, 3 checker,
|
||||
-- then 4 black rows -- the dark the flight climbs into, which is the
|
||||
-- top step, level with the wall band it is cut through. Its first and
|
||||
-- last COLUMNS are the opening's black side walls. Drawn row = depth
|
||||
-- row throughout; the rise is the only number the head-on view cannot
|
||||
-- state, and it takes the class height over the four steps like every
|
||||
-- other flight here.
|
||||
--
|
||||
-- Pinned as one cell (the class resolves off the top-left tile) but
|
||||
-- all four ids carry it, and the scan says they cannot reach anything
|
||||
-- else: 22 placements, exactly the two cells in each of the eleven
|
||||
-- Centers, and the Celadon Hotel on the same id places none of them.
|
||||
-- 94 is also the map's warp tile; pins are look-only, so the warp is
|
||||
-- untouched and the steps stay walk-through.
|
||||
stair_n = { 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) and the one cell of
|
||||
-- it that carries the push bell (10, lifted off as a figure below --
|
||||
-- the pin stays as the degradation path), front face (24/25, the
|
||||
-- game's counterTiles), left end cap (56) and the Cable Club's light
|
||||
-- sections (90/91). 8px is one clean band, so the drawn front panel
|
||||
-- stands up and the counter top stays on top; at 12 they read as
|
||||
-- wall stubs
|
||||
counter = { 8, 10, 24, 25, 56, 90, 91,
|
||||
-- and the lounge couch's SEAT column with the man
|
||||
-- sitting on it. Same half-cell box: its bottom row
|
||||
@@ -979,6 +1127,74 @@ return {
|
||||
-- on the arm. The background corners around his head and the
|
||||
-- cushion wedge under his legs are the only pixels given back.
|
||||
figures = {
|
||||
-- THE PUSH BELL on the reception counter. One tile, $0A, drawn in
|
||||
-- the counter's TOP tile row at cell (3,2) -- the same cell in all
|
||||
-- eleven Centers and nowhere else on this id (scan: 11 hits, all
|
||||
-- tile (7,4)). Every other counter cell in the game runs 8 over
|
||||
-- 24/25; this one runs 8/10 over 24/25, and 10 is 8 with the bell
|
||||
-- painted into its east half.
|
||||
--
|
||||
-- It could not be a class pin: a pin resolves a whole 8x8 tile, and
|
||||
-- the tile is three quarters counter top. Pinned with the counter
|
||||
-- (which is what it was) the bell was just ink lying on the
|
||||
-- surface -- and lying on it TWICE, because the counter's one top
|
||||
-- row had to cover a 16px-deep plot and the mesher repeated it (see
|
||||
-- the half-cell rule in ChunkMesher: fixed, and the two stacked
|
||||
-- bells were what showed it).
|
||||
--
|
||||
-- So it is lifted off by mask, exactly like the Marts' till, and
|
||||
-- `under` puts plain 8 back -- the counter top the artist drew for
|
||||
-- every other cell of the same run, so nothing is synthesized and
|
||||
-- the surface closes up seamlessly.
|
||||
--
|
||||
-- Unlike the till it is NOT an extrusion of its drawing. Seven
|
||||
-- pixels by six of ¾-view dome state a round object and nothing
|
||||
-- else usable: every reading that turns six rows into geometry
|
||||
-- invents more than it measures. So the solid is AUTHORED (see
|
||||
-- TileShape's `model`) -- a 5x3 puck with its corners taken off,
|
||||
-- one voxel proud of the counter, with a single button voxel at
|
||||
-- its centre. `pixels` stays as the segmentation: it is what says
|
||||
-- where on the tile the bell is, and the model centres on it.
|
||||
--
|
||||
-- COLOUR is still not authored. Each layer names the texel its
|
||||
-- faces wear, and all four come off tile 8 -- the counter's own
|
||||
-- plain top, whose first rows are one flat shade each: row 0 its
|
||||
-- black back edge, row 1 its white highlight, row 5 its light
|
||||
-- band. So the puck's sides are the desk's own light shade, its
|
||||
-- top the desk's own white, and the button's sides the desk's own
|
||||
-- black, and all four follow every palette bake with it.
|
||||
--
|
||||
-- It stands at the FRONT of the counter cell: a service bell is on
|
||||
-- the customer's side of the desk, and this is the only object in
|
||||
-- the profile whose depth its drawing does not state. `inset` 2
|
||||
-- backs it off the counter's own front lip -- flush read as balanced
|
||||
-- on the edge; this is the number to move to slide it either way.
|
||||
{
|
||||
w = 1,
|
||||
inset = 2,
|
||||
tiles = { 10 },
|
||||
under = { 8 },
|
||||
model = {
|
||||
{ plan = { "0xxx0",
|
||||
"xxxxx",
|
||||
"0xxx0" },
|
||||
top = { 8, 1 }, side = { 8, 5 } },
|
||||
{ plan = { "00000",
|
||||
"00x00",
|
||||
"00000" },
|
||||
top = { 8, 1 }, side = { 8, 0 } },
|
||||
},
|
||||
pixels = {
|
||||
"........",
|
||||
"........",
|
||||
"...XXX..",
|
||||
"..XXXXX.",
|
||||
".XXXXXXX",
|
||||
".XXXXXXX",
|
||||
"..XXXXX.",
|
||||
"...XXX..",
|
||||
},
|
||||
},
|
||||
{
|
||||
w = 3,
|
||||
tiles = { 36, 37, 57,
|
||||
@@ -1441,6 +1657,14 @@ return {
|
||||
wall = { 1, 2, 3, 6, 18, 19, 22, 33, 46, 47,
|
||||
62, 63, 68, 70, 71, 72, 73, 75, 76, 77, 78, 79, 84,
|
||||
88, 89, 91, 92, 93 },
|
||||
-- The Rocket lift's CAR DOORS (40 over 56, cells (2,1) and (3,1) of
|
||||
-- ROCKET_HIDEOUT_ELEVATOR) cap with the cabin frame's lower course
|
||||
-- -- the tile cell (2,0) draws beneath its own top band, and what
|
||||
-- every other column of that wall already caps with. Keyed by tile
|
||||
-- rather than blanket, unlike the houses: this one atlas dresses the
|
||||
-- department store, the Game Corner, Silph's floors and the roof
|
||||
-- too, and none of those wall tops is a lift frame.
|
||||
wall_top = { [40] = 93, [56] = 93 },
|
||||
-- 3F's television sets ($0E/$0F/$1E/$1F): the one drawing in this
|
||||
-- tileset that is a deliberate object with a body -- a black-framed
|
||||
-- cabinet, a bezel and a lit screen, drawn face-on -- and the same
|
||||
@@ -1488,9 +1712,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 +1740,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 +1770,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 +2064,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 +3144,62 @@ return {
|
||||
},
|
||||
},
|
||||
|
||||
-- Tiles that are FRONT art: they belong on the drawn facade and nowhere
|
||||
-- else. A building's back is the same drawing extruded straight through
|
||||
-- the footprint (lib/Buildings.lua `model`, and the volume path's north
|
||||
-- face in lib/ChunkMesher.lua), so without this every house wears a
|
||||
-- second door on its far wall and every Center a POKe sign readable
|
||||
-- backwards. A cell wearing one of these ids takes the art of the
|
||||
-- nearest ordinary cell beside it in the same tile row instead -- left or
|
||||
-- right, whichever tile that row uses more, which is what reaches PAST a
|
||||
-- gable's sloped corner for the wall behind it.
|
||||
--
|
||||
-- Windows are deliberately absent: a back wall with windows is right.
|
||||
-- These are the doorways, the hanging shop signs and the painted GYM
|
||||
-- lettering -- the three things a facade has that its back does not.
|
||||
-- Ids are per tileset, indexing that tileset's own atlas.
|
||||
frontOnly = {
|
||||
-- doorway 11/12 over 27/28 (27 is the tileset's own doorTile); the
|
||||
-- POKe (66/67) and MART (68/69) signs over their bracket row 74; the
|
||||
-- GYM lettering 47/63 painted across the gyms' upper course.
|
||||
OVERWORLD = { 11, 12, 27, 28, 47, 63, 66, 67, 68, 69, 74 },
|
||||
-- the Indigo Plateau and Victory Road entrances: the same doorway
|
||||
-- block, drawn into the cliff face.
|
||||
PLATEAU = { 11, 12, 27, 28 },
|
||||
-- the Safari Zone gate's double door, 42/43 over 58/59.
|
||||
FOREST = { 42, 43, 58, 59 },
|
||||
},
|
||||
|
||||
-- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH.
|
||||
--
|
||||
-- A route gate is walked THROUGH, so it has an opening on two opposite
|
||||
-- sides -- and the drawing can only show one of them. The overworld
|
||||
-- sprite is a facade seen face-on with a roof laid over it, so a south
|
||||
-- entrance is drawn (a doorway block in the facade's last rows, folded up
|
||||
-- by lib/Structures.lua) and a north, east or west one is drawn as
|
||||
-- NOTHING: the warp sits on the ground cell outside, the art beside it is
|
||||
-- plain wall, and top-down that reads fine because you never see the
|
||||
-- wall. In 3D you walk straight into a blank slab.
|
||||
--
|
||||
-- So the door is put back, on the face the player walks into. This names
|
||||
-- only the ART -- one 16x16 cell of the tileset's own doorway block, rows
|
||||
-- north-first, the same ids `frontOnly` above lists as facade-only.
|
||||
-- WHERE it goes is not authored at all: lib/Buildings.lua reads it off
|
||||
-- the map, from the warps that land in a gate and the building standing
|
||||
-- against them (see `sideDoors` there), because the map already states it
|
||||
-- and a hand list of thirty-odd coordinates would only be a chance to get
|
||||
-- one wrong.
|
||||
--
|
||||
-- OVERWORLD is the whole table because every such entrance in the game
|
||||
-- stands against an OVERWORLD building: the Safari Zone's north gate is a
|
||||
-- gap between two fence stubs with no drawing to carve, and the Route 22
|
||||
-- league gate on ROUTE_23 puts its warps on the road THROUGH the arch
|
||||
-- rather than against a wall. Both come out with no door, which is what
|
||||
-- they always had.
|
||||
sideDoors = {
|
||||
OVERWORLD = { { 11, 12 }, { 27, 28 } },
|
||||
},
|
||||
|
||||
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
|
||||
-- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its
|
||||
-- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
|
||||
@@ -4169,6 +4474,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 +4888,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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+140
-24
@@ -40,7 +40,6 @@ local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -48,6 +47,29 @@ local Map = require("src.world.Map")
|
||||
|
||||
local BattleScene = {}
|
||||
|
||||
-- ------- LET'S GO capture mode's stake in this scene
|
||||
--
|
||||
-- One table while a capture session runs, nil otherwise (see
|
||||
-- lib/CatchThrow.lua, which owns it):
|
||||
--
|
||||
-- hidePlayer the player's side stays out of the shot entirely -- no
|
||||
-- card here, no model (Stadium reads this same table), no
|
||||
-- pinned back pic (OverworldBattle reads it too)
|
||||
-- shrink the foe's scale while the ball drinks it in, applied
|
||||
-- about its chest so it collapses toward the beam
|
||||
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
|
||||
-- depth buffer, same flash window, same camera
|
||||
-- cast(sm) the same ball into the sun's pass
|
||||
-- sig() a term for the cached shadow signature, so a ball in
|
||||
-- flight re-casts and a resting scene does not
|
||||
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
|
||||
-- BattleState:draw wrap in the GB frame
|
||||
--
|
||||
-- It lives HERE, not on OverworldBattle, because every consumer below
|
||||
-- already requires BattleScene and the one file that writes it requires
|
||||
-- both -- this is the spot with no require cycle.
|
||||
BattleScene.capture = nil
|
||||
|
||||
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
|
||||
-- is solved against.
|
||||
BattleScene.GB_W = 160
|
||||
@@ -195,14 +217,29 @@ end
|
||||
local function monCards(arena, groundY, textures)
|
||||
local out = {}
|
||||
if not textures then return out end
|
||||
local cap = BattleScene.capture
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local tex = textures[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
-- capture mode: the player's side is out of the shot (the seat looks
|
||||
-- over an empty shoulder), and OverworldBattle.textures already
|
||||
-- skipped rendering it -- this is the belt to that suspender
|
||||
if side == "player" and cap and cap.hidePlayer then tex = nil end
|
||||
if tex and tex.canvas and cell then
|
||||
local mirror = (side == "player") and not tex.trainer
|
||||
out[#out + 1] = { tex = tex.canvas,
|
||||
model = monMatrix(tex, cell[1], groundY, cell[2],
|
||||
mirror) }
|
||||
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
|
||||
-- the foe drinking into the ball: scaled about its own chest, in
|
||||
-- world space so the composed card matrix needs no decomposition
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
local k = cap.shrink
|
||||
local ax, ay, az = cell[1], groundY + 8, cell[2]
|
||||
model = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(ax, ay, az),
|
||||
Mat4.mul(Mat4.scale(k, k, k),
|
||||
Mat4.translate(-ax, -ay, -az))),
|
||||
model)
|
||||
end
|
||||
out[#out + 1] = { tex = tex.canvas, model = model }
|
||||
end
|
||||
end
|
||||
return out
|
||||
@@ -313,6 +350,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
-- from somewhere new must be re-cast from there
|
||||
math.floor(ShadowMap.KX * 128),
|
||||
math.floor(ShadowMap.KZ * 128) }
|
||||
-- a capture session's ball moves through the sun's world too; its term
|
||||
-- is quantised inside sig() so the cache re-renders on real movement
|
||||
-- and not on every frame the ball rests
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.sig then
|
||||
local okSig, sig = pcall(cap.sig)
|
||||
parts[#parts + 1] = okSig and sig or "cap"
|
||||
end
|
||||
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
|
||||
return table.concat(parts, ",")
|
||||
end
|
||||
@@ -377,6 +422,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
-- the water. Un-snugged for the same reason: snug is a bias for a card
|
||||
-- rooted to the ground plane, and a model has thickness of its own.
|
||||
pcall(function() V.require("Stadium").cast(ShadowMap) end)
|
||||
-- the capture session's ball, by the same reasoning: real geometry, its
|
||||
-- shadow is half of what sells the arc
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.cast then pcall(cap.cast, ShadowMap) end
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -520,7 +569,25 @@ function BattleScene.render(state, arena, textures, token)
|
||||
end
|
||||
|
||||
local groundY = BattleScene.groundY(host, arena)
|
||||
local cam, pitch = BattleCam.rig(arena, groundY)
|
||||
-- A capture session brings a camera of its own: the head-on seat, on
|
||||
-- the arena's axis looking straight at the foe, in place of the solved
|
||||
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
|
||||
-- the pins, the sun, the cards yawing to the eye -- is generic over
|
||||
-- whichever camera this is.
|
||||
local cam, pitch, capFrameH
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.rig then
|
||||
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
|
||||
-- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
|
||||
-- BattleCam.rig hands back -- the only thing downstream reads it is the
|
||||
-- grass and flower pull below. A seat that declines to say stands in
|
||||
-- for a near-LEVEL one rather than a top-down one, which is what every
|
||||
-- staged seat actually is: the pull grows toward straight down, and a
|
||||
-- default that guessed the wrong end of that would spend tens of world
|
||||
-- pixels of bias on a camera standing two cells from its subject.
|
||||
if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), fh end
|
||||
end
|
||||
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
|
||||
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
|
||||
|
||||
local cx, cy = arena.mid[1], arena.mid[2]
|
||||
@@ -529,7 +596,8 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- the player's zoom is part of this: the sun's box is fitted to what the
|
||||
-- frame holds, so a shot pulled wide has to light the ground it just
|
||||
-- brought into view rather than the ground the rig alone would have
|
||||
local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
|
||||
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
|
||||
/ (BattleScene.GB_H * s)
|
||||
local vw = vh * pw / ph
|
||||
|
||||
-- the cards need the camera's eye to face it, so the rig has to be live
|
||||
@@ -571,12 +639,10 @@ function BattleScene.render(state, arena, textures, token)
|
||||
local sunWas = Voxel3D.SHADOW_ALPHA
|
||||
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
|
||||
* DayNight.shadowScale(outdoor)
|
||||
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
|
||||
-- arena is a staged shot rather than the world being walked through, and
|
||||
-- the seams are what make it read as built rather than photographed. Forced
|
||||
-- through the override so the player's own row is never written to.
|
||||
local gridWas = VoxelGrid.override
|
||||
VoxelGrid.override = true
|
||||
-- The wireframe is whatever the V-GRID row says, exactly as it is out in
|
||||
-- the world (see VoxelGrid): the arena is drawn a unit per voxel like
|
||||
-- everything else, so the seams follow the one toggle and a player who
|
||||
-- turned them off does not get them back for the length of a fight.
|
||||
local out = nil
|
||||
local ok, err = pcall(function()
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
@@ -662,6 +728,17 @@ function BattleScene.render(state, arena, textures, token)
|
||||
V.require("Stadium").draw(BattleBillboard.PULL)
|
||||
end)
|
||||
if not okStadium then V.require("Stadium").report(stadiumErr) end
|
||||
-- the capture session's Poke Ball, still inside the flash window and
|
||||
-- with the mons' own camera-ward pull, so a ball crossing in front of
|
||||
-- a card wins the depth test the way a nearer thing should
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
|
||||
-- and a shiny's arrival sparkle, last of the three so its stars add
|
||||
-- over the mon they belong to rather than under it, and still inside
|
||||
-- the flash window so a burst during a hit is lit like everything else
|
||||
pcall(function()
|
||||
V.require("ShinyFx").draw(arena, groundY, BattleBillboard.PULL)
|
||||
end)
|
||||
if flashing then Voxel3D.flatten(nil) end
|
||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||
-- gives them, measured against THIS camera's pitch rather than the
|
||||
@@ -669,11 +746,29 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- pull is also what keeps a tuft from z-fighting the floor it stands on
|
||||
local pull = VoxelScene.pull(math.max(pitch, 0.05))
|
||||
if not discs then
|
||||
-- and the WIND blowing through it, exactly as the free-roam pass
|
||||
-- switches on around its own grass draws (VoxelScene). Without this
|
||||
-- the uniform sits at the per-frame default beginScene sends -- zero,
|
||||
-- meaning "no wind" -- and the tall grass a fight is standing in goes
|
||||
-- dead still for the length of the battle while the same tufts one
|
||||
-- frame earlier, and one frame after, were moving. A staged fight is
|
||||
-- shot on the MAP, in that place's own weather and light; a frozen
|
||||
-- field is the one thing that reads as a photograph of it rather than
|
||||
-- the place itself.
|
||||
--
|
||||
-- No contact point goes with it (grassWind's px/pz are left nil, which
|
||||
-- sends the far-away sentinel): that push is a WALKER parting the grass
|
||||
-- they are stepping through, and there is nobody walking here -- the
|
||||
-- two mons stand still on their own tiles for the whole shot.
|
||||
Voxel3D.grassWind(true)
|
||||
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
-- off again before the flowers, which are not grass and have no sway
|
||||
-- of their own -- the same order the free-roam pass draws them in
|
||||
Voxel3D.grassWind(false)
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
|
||||
ShadowMap.snug(nil))
|
||||
@@ -695,25 +790,47 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- How wide one overworld square is on screen where each mon stands, in
|
||||
-- GB pixels. This is what the pics are scaled to: a mon covers its own
|
||||
-- square and no more, at whatever the drift has done to the distance.
|
||||
--
|
||||
-- Measured along BOTH map axes and answered as the larger, as a full
|
||||
-- 2D screen distance. One axis alone breaks the moment a camera looks
|
||||
-- ALONG it: the capture seat stands on the arena's own axis, and on a
|
||||
-- quarter-turned arena that axis is world X -- the ±X probe points
|
||||
-- then project to the same pixel and the span reads zero, which
|
||||
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
|
||||
local half = BattleScene.CELL / 2
|
||||
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
if not (pl and pr and el and er) then return end
|
||||
local function cellSpan(wx, wz)
|
||||
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
|
||||
lx, ly, s, pw, ph)
|
||||
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
|
||||
lx, ly, s, pw, ph)
|
||||
if not (x1 and x2 and x3 and x4) then return nil end
|
||||
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
|
||||
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
|
||||
return math.max(ew, ns)
|
||||
end
|
||||
local pSpan = cellSpan(arena.player[1], arena.player[2])
|
||||
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
|
||||
if not (pSpan and eSpan) then return end
|
||||
out = {
|
||||
canvas = canvas,
|
||||
player = { pmx, pmy },
|
||||
enemy = { emx, emy },
|
||||
playerSpan = math.abs(pr - pl),
|
||||
enemySpan = math.abs(er - el),
|
||||
playerSpan = pSpan,
|
||||
enemySpan = eSpan,
|
||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
||||
-- the two marks rather than on a fraction of the window
|
||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
||||
-- the camera and its combined matrix, for anything that reasons
|
||||
-- about this shot from outside the render -- the capture mode's
|
||||
-- throw is solved in these (aim errors along this eye's own right
|
||||
-- and forward, contact judged through this vp)
|
||||
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
|
||||
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
|
||||
vp = vp,
|
||||
-- and the hour's light, for anything drawn over this shot that is NOT
|
||||
-- geometry and so never went past the shader that applied it -- the back
|
||||
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
|
||||
@@ -725,7 +842,6 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- renders (the free-roam pipeline, next frame) must find the orbit back
|
||||
Voxel3D.camera = nil
|
||||
Voxel3D.SHADOW_ALPHA = sunWas
|
||||
VoxelGrid.override = gridWas
|
||||
if not ok then
|
||||
-- endScene never ran, so the canvas is still bound and the shader still
|
||||
-- set; put the frame back the way it was found before rethrowing
|
||||
|
||||
+491
-11
@@ -134,9 +134,119 @@ local function profile()
|
||||
end
|
||||
|
||||
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
||||
local frontSets = {} -- tileset id -> { [tile] = true } or false
|
||||
|
||||
-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
|
||||
-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
|
||||
function Buildings.sideDoorCell(tilesetId)
|
||||
local s = profile()
|
||||
return s and s.sideDoors and s.sideDoors[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- The tileset's front-only tiles as a set (data/voxel_heights.lua
|
||||
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
|
||||
-- on a facade and on no other face of the same building. nil when the
|
||||
-- tileset names none, which is every indoor one.
|
||||
function Buildings.frontOnly(tilesetId)
|
||||
local hit = frontSets[tilesetId]
|
||||
if hit == nil then
|
||||
local s = profile()
|
||||
local list = s and s.frontOnly and s.frontOnly[tilesetId]
|
||||
if list then
|
||||
hit = {}
|
||||
for _, id in ipairs(list) do hit[id] = true end
|
||||
else
|
||||
hit = false
|
||||
end
|
||||
frontSets[tilesetId] = hit
|
||||
end
|
||||
return hit or nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ read --
|
||||
|
||||
-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
|
||||
-- the front shows. The facade extrudes straight through the footprint, so
|
||||
-- the far wall is the drawing again -- and read from behind it is the
|
||||
-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
|
||||
-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
|
||||
-- wearing one takes the art of an ordinary cell beside it in the same tile
|
||||
-- row.
|
||||
--
|
||||
-- The donor is chosen per RUN of front-only cells, not per cell, so a
|
||||
-- two-tile doorway comes out as two tiles of the SAME wall rather than
|
||||
-- borrowing left from one side and right from the other. Between the two
|
||||
-- neighbours the one whose tile the row uses more often wins, which is
|
||||
-- what reaches past a gable's sloped corner (a 4x2 house draws its door
|
||||
-- against the slope: the corner is unique to the row, the wall beside it
|
||||
-- is not) for the wall the back should actually wear.
|
||||
--
|
||||
-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
|
||||
-- "unchanged"; nil when the drawing has no front-only tile at all, which is
|
||||
-- most of them.
|
||||
local function backMap(tiles, bw, bh, W, inside, frontOnly)
|
||||
if not frontOnly then return nil end
|
||||
local donor, any = {}, false
|
||||
for r = 1, bh do
|
||||
local row = tiles[r]
|
||||
local freq = {}
|
||||
for c = 1, bw do
|
||||
local id = row[c]
|
||||
if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
|
||||
end
|
||||
local c = 1
|
||||
while c <= bw do
|
||||
if frontOnly[row[c]] then
|
||||
local c1 = c
|
||||
while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
|
||||
local l, rt = c - 1, c1 + 1
|
||||
local pick = nil
|
||||
if l >= 1 and rt <= bw then
|
||||
pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
|
||||
elseif l >= 1 then
|
||||
pick = l
|
||||
elseif rt <= bw then
|
||||
pick = rt
|
||||
end
|
||||
-- a row that is front-only end to end has no donor; it keeps its
|
||||
-- own art rather than inventing one
|
||||
if pick then
|
||||
for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
|
||||
any = true
|
||||
end
|
||||
c = c1 + 1
|
||||
else
|
||||
c = c + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if not any then return nil end
|
||||
|
||||
local back = {}
|
||||
for cell, dc in pairs(donor) do
|
||||
local r, c = math.floor(cell / bw), cell % bw
|
||||
for oy = 0, 7 do
|
||||
local sy = r * 8 + oy
|
||||
for ox = 0, 7 do
|
||||
local i = sy * W + c * 8 + ox
|
||||
local j = sy * W + dc * 8 + ox
|
||||
-- The donor must be DRAWN, or the substitution would hand the wall
|
||||
-- a texel from outside the silhouette. One row up is tried first,
|
||||
-- because the drawing's last row is the black threshold the
|
||||
-- building stands on: the doorway paints it (a door sits on the
|
||||
-- ground) and the wall beside it does not, so at the base course
|
||||
-- the same row of the donor column is off the shape. The model
|
||||
-- lifts that column's foot by exactly one row for the same reason
|
||||
-- (see `at`), and lifting the donor with it is what makes the back
|
||||
-- wall's bottom course continuous.
|
||||
if not inside[j] then j = j - W end
|
||||
if j >= 0 and inside[j] then back[i] = j end
|
||||
end
|
||||
end
|
||||
end
|
||||
return back
|
||||
end
|
||||
|
||||
-- Composite the template out of the atlas and flood the silhouette in from
|
||||
-- the border. Returns flat arrays indexed y * W + x.
|
||||
--
|
||||
@@ -149,7 +259,83 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
||||
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
|
||||
-- is built from the complete drawing and the tower rises to its real
|
||||
-- height instead of folding as two half-buildings.
|
||||
local function read(t, data, perRow)
|
||||
-- Composite one doorway cell PAST the end of the sprite, at indices
|
||||
-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
|
||||
-- sprite indices like everything else -- `emit` resolves a voxel's colour
|
||||
-- through sp.ax/sp.ay and knows nothing about where the index came from --
|
||||
-- so the door only has to BE in the sprite arrays to travel the rest of
|
||||
-- the pipeline untouched. Appending rather than drawing into the grid is
|
||||
-- the point: the drawing itself must not change, or the silhouette flood,
|
||||
-- the taper and every measured band would be read off art the tileset
|
||||
-- never placed here.
|
||||
--
|
||||
-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
|
||||
-- flood both stop there), so the block is invisible to measurement and
|
||||
-- visible only to the code that asks for it by index.
|
||||
--
|
||||
-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
|
||||
-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
|
||||
-- ring is the wall beside and above the frame, and its last row is the
|
||||
-- black threshold the building stands on with the door's own step cut into
|
||||
-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
|
||||
-- front-wall art around every door.
|
||||
--
|
||||
-- The front facade tells the ring from the door by flooding: the wall
|
||||
-- around the door is one region with the whole facade, far too big to be a
|
||||
-- pane, and only what the black frame SEALS sinks. The same test, bounded
|
||||
-- to the block: flood the left, right and top edges through their own
|
||||
-- shade class, and what the flood reaches is context -- left unpainted, so
|
||||
-- the flank keeps the texel it already had. Not the bottom edge, because
|
||||
-- the bottom edge is the ground: the step under the door is sealed there
|
||||
-- on the drawn facade too, and it recesses with the rest of the doorway.
|
||||
--
|
||||
-- What is painted then splits the way a facade's does: black is frame and
|
||||
-- stays flush with the wall, everything it seals sinks a voxel behind it.
|
||||
local function readDoor(sp, data, perRow, cell)
|
||||
local base = sp.W * sp.H
|
||||
local black = {}
|
||||
for dy = 0, 15 do
|
||||
local row = cell[math.floor(dy / 8) + 1]
|
||||
for dx = 0, 15 do
|
||||
local tile = row[math.floor(dx / 8) + 1]
|
||||
local px = (tile % perRow) * 8 + dx % 8
|
||||
local py = math.floor(tile / perRow) * 8 + dy % 8
|
||||
local k = dy * 16 + dx
|
||||
local i = base + k
|
||||
sp.ax[i], sp.ay[i] = px, py
|
||||
local r, g, b, a = data:getPixel(px, py)
|
||||
sp.col[i] = shadeOf(r, g, b, a)
|
||||
sp.inside[i] = true
|
||||
black[k] = sp.col[i] == BLACK
|
||||
end
|
||||
end
|
||||
|
||||
local context, stack = {}, {}
|
||||
local function seed(dx, dy, cls)
|
||||
if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
|
||||
local k = dy * 16 + dx
|
||||
if context[k] or black[k] ~= cls then return end
|
||||
context[k] = true
|
||||
stack[#stack + 1] = k
|
||||
end
|
||||
for dy = 0, 15 do
|
||||
seed(0, dy, black[dy * 16])
|
||||
seed(15, dy, black[dy * 16 + 15])
|
||||
end
|
||||
for dx = 0, 15 do seed(dx, 0, black[dx]) end
|
||||
while #stack > 0 do
|
||||
local k = table.remove(stack)
|
||||
local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
|
||||
seed(dx + 1, dy, cls)
|
||||
seed(dx - 1, dy, cls)
|
||||
seed(dx, dy + 1, cls)
|
||||
seed(dx, dy - 1, cls)
|
||||
end
|
||||
|
||||
sp.door = { base = base, black = black, context = context }
|
||||
end
|
||||
|
||||
local function read(t, data, perRow, frontOnly)
|
||||
local tiles = t.tiles
|
||||
if t.topRows then
|
||||
tiles = {}
|
||||
@@ -244,7 +430,8 @@ local function read(t, data, perRow)
|
||||
end
|
||||
end
|
||||
end
|
||||
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
|
||||
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
|
||||
back = backMap(tiles, bw, bh, W, inside, frontOnly) }
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- measure --
|
||||
@@ -457,7 +644,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
|
||||
@@ -582,6 +770,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]
|
||||
@@ -894,11 +1170,113 @@ local function deskSetModel(sp, pr, t)
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- ------- the doorway a gate house is entered by from a side the drawing
|
||||
-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
|
||||
-- below for how the placements are found).
|
||||
--
|
||||
-- One cell of the tileset's own doorway art, standing on the ground of the
|
||||
-- face the player walks into, and hung by the SAME rule the drawn facade
|
||||
-- hangs its own door by: the art's black frame stays flush with the wall
|
||||
-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
|
||||
-- applied here by hand because the art is not in the drawing to be flooded
|
||||
-- with it). So a side door and a front door are the same depth of the same
|
||||
-- opening, and the jamb faces the recess exposes come out of the mesher for
|
||||
-- free, wearing the frame's own texels.
|
||||
--
|
||||
-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
|
||||
-- picks it per voxel, so which art column lands at which world coordinate
|
||||
-- is decided HERE and nowhere else -- and a face is read from outside, so
|
||||
-- the art's own left-to-right runs with the viewer's, not with the world's:
|
||||
-- facing east at a west wall, south is to your right (+z); facing west at
|
||||
-- an east wall, north is (-z); facing south at a north wall, west is (-x).
|
||||
-- Two of the three are mirrored against the axis, which is the same reason
|
||||
-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
|
||||
local DOOR = 16
|
||||
|
||||
local function sideDoors(at, sp, doors, W)
|
||||
if not (doors and #doors > 0 and sp.door) then return at end
|
||||
local art = sp.door
|
||||
|
||||
-- The face's OUTER surface, walked in from the box edge until the wall
|
||||
-- answers. A drawing inset from its own grid (B03's outer columns are
|
||||
-- terrain, not building) stands its flank a column or two in, and a door
|
||||
-- pinned to the box edge would hang in the air beside it.
|
||||
local function faceX(from, step, off)
|
||||
for k = 0, W - 1 do
|
||||
local x = from + step * k
|
||||
for y = 0, DOOR - 1 do
|
||||
for z = off, off + DOOR - 1 do
|
||||
if at(x, y, z) then return x end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local list = {}
|
||||
for _, d in ipairs(doors) do
|
||||
local face = 0 -- north: the facade's own z origin
|
||||
if d.side == "w" then face = faceX(0, 1, d.at)
|
||||
elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
|
||||
if face then
|
||||
list[#list + 1] = { side = d.side, off = d.at, face = face }
|
||||
end
|
||||
end
|
||||
if #list == 0 then return at end
|
||||
|
||||
-- art column at (x, z) for door `e`, or nil when the voxel is not in it
|
||||
local function column(e, x, z)
|
||||
if e.side == "n" then
|
||||
if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
|
||||
return e.off + DOOR - 1 - x, z == 0
|
||||
end
|
||||
elseif z >= e.off and z < e.off + DOOR then
|
||||
if e.side == "w" then
|
||||
if x == e.face then return z - e.off, true end
|
||||
if x == e.face + 1 then return z - e.off, false end
|
||||
else
|
||||
if x == e.face then return e.off + DOOR - 1 - z, true end
|
||||
if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return function(x, y, z)
|
||||
local v = at(x, y, z)
|
||||
-- no wall here is the end of it: a door is hung ON the building, and
|
||||
-- nothing about it may add geometry the drawing does not stand up
|
||||
if v == nil or y >= DOOR then return v end
|
||||
for _, e in ipairs(list) do
|
||||
local c, outer = column(e, x, z)
|
||||
if c then
|
||||
-- art row 0 is the door's head, so the ground row is its last
|
||||
local k = (DOOR - 1 - y) * DOOR + c
|
||||
-- the art's own ring: wall, not door. The flank keeps its texel.
|
||||
if art.context[k] then return v end
|
||||
if art.black[k] then
|
||||
-- the frame, flush with the wall; behind it the wall stands on
|
||||
if outer then return art.base + k end
|
||||
return v
|
||||
end
|
||||
-- and what the frame seals sinks: the face voxel goes, the one
|
||||
-- behind it wears the art. (Written long: `outer and nil or i`
|
||||
-- returns i for BOTH, nil being false to `and`.)
|
||||
if outer then return nil end
|
||||
return art.base + k
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
end
|
||||
|
||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||
-- are trimmed to its underside so nothing pokes through the surface.
|
||||
local function model(sp, pr, t)
|
||||
-- are trimmed to its underside so nothing pokes through the surface. A
|
||||
-- gate's side doors are hung on the finished lookup, last of all, because
|
||||
-- they answer to the FACE rather than to any band of the drawing.
|
||||
local function model(sp, pr, t, doors)
|
||||
if t.parts then return deskSetModel(sp, pr, t) end
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
@@ -945,6 +1323,11 @@ local function model(sp, pr, t)
|
||||
local T = {}
|
||||
for x = 0, W - 1 do T[x] = ytop - top[x] end
|
||||
|
||||
-- the back layer's texel: the drawing again, minus what only the front
|
||||
-- may wear (see backMap)
|
||||
local back = sp.back
|
||||
local function backOf(i) return (back and back[i]) or i end
|
||||
|
||||
local function at(x, y, z)
|
||||
if x < 0 or x >= W then return nil end
|
||||
local tx = T[x]
|
||||
@@ -981,7 +1364,11 @@ local function model(sp, pr, t)
|
||||
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
|
||||
local sy = ground - 1 - y
|
||||
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
||||
return sy * W + x
|
||||
-- z < 0 is the awning's NORTH end: same substitution the wall
|
||||
-- behind it makes, so a band that carries a sign does not carry
|
||||
-- it round the back
|
||||
local i = sy * W + x
|
||||
return z < 0 and backOf(i) or i
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -1003,10 +1390,12 @@ local function model(sp, pr, t)
|
||||
if pr.recess[i] then return nil end
|
||||
return i
|
||||
end
|
||||
if z == 0 then return i end
|
||||
if z == 0 then return backOf(i) end
|
||||
return pr.interior[i]
|
||||
end
|
||||
|
||||
at = sideDoors(at, sp, doors, W)
|
||||
|
||||
return { at = at, W = W, ytop = ytop,
|
||||
zmin = ledge0 and -2 or 0,
|
||||
zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
|
||||
@@ -1218,6 +1607,82 @@ local function matches(S, t, tx, ty)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- which of a placement's faces a gate is entered by
|
||||
--
|
||||
-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
|
||||
-- gate house, and the face is whichever one of this placement the warp cell
|
||||
-- stands against. Thirty-odd doors fall out of two lines of geometry, and
|
||||
-- none of them can drift out of step with a map edit the way a hand list
|
||||
-- would. Three tests, each of them load-bearing:
|
||||
--
|
||||
-- the destination is a GATE tileset -- what makes a building a gate house
|
||||
-- rather than a house with a back door. GATE and FOREST_GATE both, so
|
||||
-- the Viridian Forest pair count; the Safari rest houses are on GATE
|
||||
-- too and are excluded by the next test, their warps being drawn doors
|
||||
-- already.
|
||||
-- the cell is not already a door tile -- a south entrance IS drawn, as a
|
||||
-- doorway block in the facade, and lib/Structures.lua folds it up into
|
||||
-- the front face. Adding a second one there would fight it.
|
||||
-- the cell is WALKABLE -- the ROM gives an unreachable twin warp to
|
||||
-- several gates (a fence cell beside the real opening on Route 7 west
|
||||
-- and Route 16 east, a tree beside Route 6's), and a door on the wall
|
||||
-- behind a fence is a door into nothing. The reachable cells are the
|
||||
-- entrance, and two of them side by side are the gate's real two-cell
|
||||
-- opening, which comes out as the double door it always was.
|
||||
--
|
||||
-- The south face is skipped whether or not it is drawn: it is the one face
|
||||
-- the drawing states in full, so anything it needs it already has.
|
||||
local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
-- through the module, NOT a global: `Game` is a local everywhere in the
|
||||
-- engine (`local Game = require("src.core.Game")` in a dozen files) and
|
||||
-- reading `_G.Game` came back nil every time -- which fails silently and
|
||||
-- exactly like the feature being off, because a nil map table is also
|
||||
-- what a headless build legitimately has.
|
||||
local ok, G = pcall(require, "src.core.Game")
|
||||
local defs = ok and G and G.data and G.data.maps
|
||||
local warps = map.def and map.def.warps
|
||||
if not (defs and warps and warps[1]) then return nil end
|
||||
if not Buildings.sideDoorCell(tileset.id) then return nil end
|
||||
|
||||
local out = nil
|
||||
for _, w in ipairs(warps) do
|
||||
local dest = defs[w.destMap]
|
||||
if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
|
||||
and map:isWalkableCell(w.x, w.y)
|
||||
and not map:isDoorTileCell(w.x, w.y) then
|
||||
-- the cell in the model's own pixels: a cell is two tiles, a tile
|
||||
-- eight pixels, and the placement's origin is (tx, ty) in tiles
|
||||
local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
|
||||
local side = nil
|
||||
if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
|
||||
elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
|
||||
elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
|
||||
if side then
|
||||
out = out or {}
|
||||
out[#out + 1] = { side = side, at = side == "n" and lx or lz }
|
||||
end
|
||||
end
|
||||
end
|
||||
if out then
|
||||
table.sort(out, function(a, b)
|
||||
if a.side ~= b.side then return a.side < b.side end
|
||||
return a.at < b.at
|
||||
end)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The model cache key's door half. A template's doors belong to the
|
||||
-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
|
||||
-- sit at different rows of it on each -- so two placements of one drawing
|
||||
-- are two models, and only placements that agree share one.
|
||||
local function doorKey(doors)
|
||||
if not doors then return "" end
|
||||
local parts = {}
|
||||
for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
|
||||
return "#" .. table.concat(parts, ",")
|
||||
end
|
||||
|
||||
-- Find every placement of every template for this map's tileset, build one
|
||||
-- model per template, and stamp it. Returns nothing; the quads land in
|
||||
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
|
||||
@@ -1238,6 +1703,9 @@ function Buildings.build(S, map, data, perRow)
|
||||
if type(t.tiles) == "table" and #t.tiles > 0 then
|
||||
local bh, bw = #t.tiles, #t.tiles[1]
|
||||
local first = t.tiles[1][1]
|
||||
-- the model this placement stamps. Not hoisted out of the loops any
|
||||
-- more: a template's doors belong to the placement, so two hits of
|
||||
-- one drawing on the same map can be two models (see doorKey).
|
||||
local built = nil
|
||||
for ty = 0, th - bh do
|
||||
Budget.tick()
|
||||
@@ -1263,8 +1731,13 @@ function Buildings.build(S, map, data, perRow)
|
||||
end
|
||||
end
|
||||
if free and matches(S, t, tx, ty) then
|
||||
if not built then
|
||||
local key = tileset.id .. ":" .. index
|
||||
do
|
||||
-- keyed per PLACEMENT once a gate's doors are in play (see
|
||||
-- doorKey): the drawing is shared, the openings are not
|
||||
local doors = not t.claimOnly
|
||||
and sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
or nil
|
||||
local key = tileset.id .. ":" .. index .. doorKey(doors)
|
||||
if not models[key] then
|
||||
if t.claimOnly then
|
||||
-- claim the cells, stamp nothing: the drawing here is
|
||||
@@ -1274,9 +1747,15 @@ function Buildings.build(S, map, data, perRow)
|
||||
-- detector they stood as a second half-building.
|
||||
models[key] = {}
|
||||
else
|
||||
local sp = read(t, data, perRow)
|
||||
local sp = read(t, data, perRow,
|
||||
Buildings.frontOnly(tileset.id))
|
||||
if doors then
|
||||
readDoor(sp, data, perRow,
|
||||
Buildings.sideDoorCell(tileset.id))
|
||||
end
|
||||
local pr = measure(sp, t)
|
||||
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
|
||||
models[key] = emit(model(sp, pr, t, doors), sp,
|
||||
atlasW, atlasH)
|
||||
end
|
||||
end
|
||||
built = models[key]
|
||||
@@ -1385,6 +1864,7 @@ end
|
||||
function Buildings.invalidate()
|
||||
spec = nil
|
||||
models = {}
|
||||
frontSets = {}
|
||||
end
|
||||
|
||||
return Buildings
|
||||
|
||||
+1770
File diff suppressed because it is too large
Load Diff
+122
-13
@@ -52,6 +52,7 @@ local V = ...
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Structures = V.require("Structures")
|
||||
local Buildings = V.require("Buildings")
|
||||
local TileShape = V.require("TileShape")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Budget = V.require("BuildBudget")
|
||||
@@ -64,6 +65,26 @@ end
|
||||
|
||||
local ChunkMesher = {}
|
||||
|
||||
-- Which drawn row a FLAT-topped volume's top face wears at depth `ty`.
|
||||
--
|
||||
-- A structure is usually deeper than the art that draws it, so the rows
|
||||
-- cycle and the drawing repeats down the top. That is right for art which
|
||||
-- genuinely repeats -- the Safari Zone's fence alternates two tiles the
|
||||
-- whole way down -- and wrong for a RIM over a uniform body: a cliff
|
||||
-- mound's first row is its top edge, and cycling lays that edge again
|
||||
-- every second tile, striping a plateau with rims it should not have.
|
||||
--
|
||||
-- Where Structures found the body uniform, the rim is laid once at the
|
||||
-- north edge and the body held after it. Everything else cycles as before.
|
||||
function ChunkMesher.flatTopRow(run, ty)
|
||||
local m = math.min(2, run.extent)
|
||||
local d = ty - run.north
|
||||
if run.topUniform then
|
||||
return run.north + math.min(d, m - 1)
|
||||
end
|
||||
return run.north + (d % m)
|
||||
end
|
||||
|
||||
-- Ring of border blocks meshed around the body, matching the width
|
||||
-- TileRenderer draws so the two modes end at the same place.
|
||||
local RING = 3
|
||||
@@ -102,6 +123,12 @@ local SIDES = {
|
||||
{ 0, -1, 6 }, -- -Z north
|
||||
}
|
||||
|
||||
-- How far sideways a face reaches for ordinary wall when the column it
|
||||
-- stands over draws a doorway or a sign (see wallTile), nearest ring
|
||||
-- first. Left before right at each distance is arbitrary and only decides
|
||||
-- symmetric cases.
|
||||
local SPAN = { { -1, 1 }, { -2, 2 } }
|
||||
|
||||
local function keyOf(tx, ty)
|
||||
return (ty + 64) * 4096 + (tx + 64)
|
||||
end
|
||||
@@ -248,6 +275,45 @@ 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
|
||||
|
||||
-- The capping course an interior wall wears on its TOP face (see
|
||||
-- TileShape.wallTop). A wall band folds entirely onto its own face, so
|
||||
-- the top had nothing left to lay flat and repeated the face -- a house's
|
||||
-- town-map poster and window, a Center's pokeball poster and the Rocket
|
||||
-- lift's doors came out lying across the top of the wall as well as
|
||||
-- standing in it. Only the top is redirected: the face still draws what
|
||||
-- the map draws.
|
||||
local wallTop = TileShape.wallTop(tileset.id)
|
||||
local function capOf(s, tile)
|
||||
if not (wallTop and s.class == "wall") then return nil end
|
||||
return wallTop(tile)
|
||||
end
|
||||
|
||||
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
|
||||
local function uvRect(tile, vTop, vBot)
|
||||
local ax = (tile % perRow) * 8
|
||||
@@ -370,8 +436,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
|
||||
-- `to` routes the quad somewhere other than the main sink -- the water
|
||||
-- surface is the only caller that ever does (see runGeometry's header).
|
||||
local function topQuad(x0, z0, h, tile, shade, to)
|
||||
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||
-- `vTop`/`vBot` crop the art to a row range of the tile, which only the
|
||||
-- half-cell furniture rule below ever asks for: a top band that has to
|
||||
-- cover more depth than it was drawn with hands each 8px cell its own
|
||||
-- slice of the band instead of the whole of it.
|
||||
local function topQuad(x0, z0, h, tile, shade, to, vTop, vBot)
|
||||
local u0, u1, v0, v1 = uvRect(tile, vTop or 0, vBot or 8)
|
||||
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||
@@ -528,11 +598,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
{ x0 + 8, neY, z0 }, { x0, nwY, z0 } },
|
||||
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95)
|
||||
elseif run then
|
||||
local m = math.min(2, run.extent)
|
||||
local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m))
|
||||
local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty))
|
||||
-- a DETECTED wall volume caps the same way a pinned one does:
|
||||
-- the Rocket lift's cabin doors are found rather than pinned,
|
||||
-- and their drawing lay across the top of the wall they are set
|
||||
-- into (see wallTop)
|
||||
topTile = capOf(s, tile) or topTile
|
||||
topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
|
||||
else
|
||||
local topTile = tile
|
||||
local vTop, vBot = nil, nil
|
||||
if s.art == "upright" and s.authored then
|
||||
-- Top art for a pinned box. A furniture drawing is top-view
|
||||
-- rows over floor(h/8) face-on rows the fold stands upright;
|
||||
@@ -559,6 +634,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
end
|
||||
end
|
||||
local row = math.min(ty, front - math.floor(h / 8))
|
||||
-- HALF-CELL FURNITURE, one cell of plot: the drawing gives ONE
|
||||
-- tile row of top view (the counter's surface) over one that
|
||||
-- folds up as the face (its front panel), and the plot under it
|
||||
-- is 16px deep. Repeating the top row over both depth rows --
|
||||
-- what `row` above resolves to, since the face row has no top
|
||||
-- art of its own to wear -- draws the surface TWICE: the
|
||||
-- Centers' counters ran a black back edge and its white
|
||||
-- highlight down the middle of every counter, and the push bell
|
||||
-- drawn on one of them came out as two bells stacked front to
|
||||
-- back. The band is foreshortened, not tiled, so each depth row
|
||||
-- takes HALF of it and the one drawing covers the whole top.
|
||||
--
|
||||
-- Deliberately narrow: only a run that is exactly one cell deep
|
||||
-- with exactly one top row. A deeper run states its own depth
|
||||
-- 1:1 already (the lounge couch is four tile rows over two
|
||||
-- cells, and its cushions must stay cushion-sized), and only the
|
||||
-- last of its rows repeats -- which is the drawing tiling, not
|
||||
-- a surface drawn once and stretched.
|
||||
local face = math.floor(h / 8)
|
||||
if front - face - north == 0 and front - north == 1 then
|
||||
local k = ty - north
|
||||
row = north
|
||||
vTop, vBot = k * 4, k * 4 + 4
|
||||
end
|
||||
if row < north then
|
||||
-- the whole run folded onto the face: top with the drawn
|
||||
-- row just above it when that row is furniture too (a
|
||||
@@ -568,7 +667,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
row = (above and above.authored and above.art == "upright")
|
||||
and (north - 1) or north
|
||||
end
|
||||
topTile = S.tileAt[keyOf(tx, row)]
|
||||
topTile = capOf(s, tile) or S.tileAt[keyOf(tx, row)]
|
||||
end
|
||||
-- water's surface, and only water's: the recessed sheet itself,
|
||||
-- never the ground's shoreline bands around it. A cell an object
|
||||
@@ -577,7 +676,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- on the pond.
|
||||
topQuad(x0, z0, h, topTile,
|
||||
s.art == "upright" and VOLUME_TOP_SHADE or 1,
|
||||
(s.class == "water") and waterPush or nil)
|
||||
(s.class == "water") and waterPush or nil, vTop, vBot)
|
||||
end
|
||||
|
||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||
@@ -605,13 +704,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 +914,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 +941,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
@@ -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
|
||||
@@ -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
|
||||
+65
-1
@@ -51,6 +51,26 @@ 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.
|
||||
--
|
||||
@@ -416,6 +436,26 @@ local RAY_SHADER = [[
|
||||
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);
|
||||
@@ -492,7 +532,8 @@ local RAY_SHADER = [[
|
||||
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;
|
||||
acc += trans * lit * dapple * fadeIn * foot * dens * dt
|
||||
* dioramaCull(p);
|
||||
}
|
||||
trans *= exp(-fogW.x * 0.5 * dt);
|
||||
}
|
||||
@@ -587,6 +628,9 @@ local PART_SHADER = [[
|
||||
#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;
|
||||
@@ -606,6 +650,20 @@ local PART_SHADER = [[
|
||||
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);
|
||||
@@ -763,6 +821,9 @@ function ForestAtmos.draw(map)
|
||||
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,
|
||||
@@ -793,6 +854,9 @@ function ForestAtmos.draw(map)
|
||||
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)
|
||||
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
-- LET'S GO: the row, the modes, and every engine seam the capture game
|
||||
-- stands on.
|
||||
--
|
||||
-- Three rungs:
|
||||
--
|
||||
-- OFF nothing changes. The default, and what an unrecognised
|
||||
-- stored value falls back to.
|
||||
-- FULL the whole Let's Go treatment. A wild encounter opens
|
||||
-- STRAIGHT into capture mode (B backs out to the classic
|
||||
-- menu for anyone who came to fight), Poke/Great/Ultra
|
||||
-- Balls are half price at every mart, and EXPERIENCE works
|
||||
-- the way that game's does: every healthy party member
|
||||
-- gains from every catch AND every trainer knockout, each
|
||||
-- measured against its own level. A catch adds the throw
|
||||
-- stack on top -- grade, first throw, new species, combo.
|
||||
-- CATCH ONLY the fights are untouched and the shops are untouched;
|
||||
-- the one change is that throwing a ball -- from the bag,
|
||||
-- or a SAFARI BALL from the safari menu -- runs the throw
|
||||
-- minigame instead of the automatic toss. The minigame's
|
||||
-- grade still folds into the Gen 1 catch roll (a good
|
||||
-- throw should matter or the ring is a lie), but nothing
|
||||
-- outside the throw changes.
|
||||
--
|
||||
-- The capture game itself lives in lib/CatchThrow.lua and the ball it
|
||||
-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
|
||||
-- the two BattleState wraps that intercept a ball being thrown, the
|
||||
-- auto-entry tick for FULL, the price patch, and the experience hooks.
|
||||
--
|
||||
-- ------- where the minigame declines to run
|
||||
--
|
||||
-- The throw is a 3D scene: it needs the staged battle standing (the
|
||||
-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
|
||||
-- default), a driver with a depth buffer, and a flat screen (the VR seat
|
||||
-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
|
||||
-- switched off, a headless driver, a headset -- the ball quietly takes
|
||||
-- the engine's own toss, which is exactly what the mod's "declines
|
||||
-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
|
||||
-- old man's demo keep the vanilla path on purpose: those branches ARE
|
||||
-- their behaviour.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local CatchThrow = V.require("CatchThrow")
|
||||
|
||||
local LetsGo = {}
|
||||
|
||||
LetsGo.KEY = "letsgo"
|
||||
LetsGo.LABEL = "LET'S GO"
|
||||
|
||||
-- `false` first: the default, and the fallback for a stored value from
|
||||
-- some other version of this ladder
|
||||
LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
|
||||
{ false, "full", "catching" },
|
||||
{ "OFF", "FULL", "CATCH ONLY" })
|
||||
|
||||
-- false | "full" | "catching"
|
||||
function LetsGo.mode()
|
||||
return LetsGo.setting:get()
|
||||
end
|
||||
|
||||
local function game() return require("src.core.Game") end
|
||||
|
||||
-- ------- half-price balls (FULL)
|
||||
--
|
||||
-- Prices are live data (game.data.items[id].price) and every reader --
|
||||
-- the buy list, the affordability check, the quantity box -- reads them
|
||||
-- per use, so patching the table IS the feature. Applied and reverted on
|
||||
-- the option's edge, polled from the tick because the row, the manager's
|
||||
-- page and a loaded save can all move it and none of them announces to
|
||||
-- us. The sell price follows automatically (the mart pays half of list),
|
||||
-- which is coherent: cheaper balls are worth less back too.
|
||||
local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
|
||||
local fullPrices = nil -- originals while halved, or nil
|
||||
|
||||
local function applyPrices()
|
||||
local g = game()
|
||||
local items = g and g.data and g.data.items
|
||||
if not items then return end
|
||||
local wantHalf = LetsGo.mode() == "full"
|
||||
if wantHalf and not fullPrices then
|
||||
fullPrices = {}
|
||||
for _, id in ipairs(PRICED) do
|
||||
local def = items[id]
|
||||
if def and def.price then
|
||||
fullPrices[id] = def.price
|
||||
def.price = math.floor(def.price / 2)
|
||||
end
|
||||
end
|
||||
elseif not wantHalf and fullPrices then
|
||||
for id, price in pairs(fullPrices) do
|
||||
if items[id] then items[id].price = price end
|
||||
end
|
||||
fullPrices = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- whether a throw can be the minigame
|
||||
|
||||
local function vrOn()
|
||||
local ok, vr = pcall(V.require, "VR")
|
||||
return ok and vr and vr.enabled and vr.enabled() or false
|
||||
end
|
||||
|
||||
-- ------- the battles that are cutscenes wearing a battle's clothes
|
||||
--
|
||||
-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK
|
||||
-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one
|
||||
-- flag covers both) -- are scripted from the first frame: the cursor moves
|
||||
-- itself, the bag opens itself, the ball is thrown by someone who is not
|
||||
-- the player, and the throw always catches a Pokemon nobody keeps. There
|
||||
-- is no decision in them to hand a minigame, and the story beat is the
|
||||
-- point, so LET'S GO stays out of them entirely at whatever rung: no
|
||||
-- capture screen, no FULL treatment, no experience.
|
||||
function LetsGo.scripted(battle)
|
||||
return battle and (battle.demo or battle.oakDemo) and true or false
|
||||
end
|
||||
|
||||
function LetsGo.wantsMinigame(battle)
|
||||
if not LetsGo.mode() then return false end
|
||||
if not battle or battle.kind ~= "wild" then return false end
|
||||
if LetsGo.scripted(battle) then return false end
|
||||
if battle.ghost or battle.noCatch then return false end
|
||||
if not Voxel3D.available() or vrOn() then return false end
|
||||
-- the staged shot must actually be standing: this is "there is a 3D
|
||||
-- battle on screen right now", which the throw is aimed into
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot) and true or false
|
||||
end
|
||||
|
||||
-- A Let's Go wild: the encounters FULL owns outright. In these the foe
|
||||
-- never takes a turn, the player's Pokemon is never sent out or shown,
|
||||
-- B runs (and always escapes), and the encounter lives in throw mode
|
||||
-- from the wipe to the last message.
|
||||
function LetsGo.fullWild(battle)
|
||||
return LetsGo.mode() == "full" and battle and battle.kind == "wild"
|
||||
and not LetsGo.scripted(battle)
|
||||
and not (battle.safari or battle.ghost or battle.noCatch)
|
||||
and true or false
|
||||
end
|
||||
|
||||
-- ------- the experience stack (FULL)
|
||||
--
|
||||
-- Let's Go pays a catch like a knockout, through the Gen VII scaled
|
||||
-- formula -- every party member paid against its OWN level -- times the
|
||||
-- catch bonuses. Three engine hooks carry it:
|
||||
--
|
||||
-- battle.catch_exp "does a catch pay at all" -- yes, under FULL
|
||||
-- battle.exp_award the distribution: every healthy party member its
|
||||
-- own full share, no participant split
|
||||
-- exp.gain the amount: the scaled formula times the bonus
|
||||
-- stack, in place of floor(b*L/7)
|
||||
--
|
||||
-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
|
||||
-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
|
||||
-- combo tier. Traded 1.5 still rides through the engine's own flag.
|
||||
local expCtx = nil -- {battle, mult} while a Let's Go catch pays out
|
||||
local granting = nil -- set across the applyShare loop for exp.gain
|
||||
|
||||
local function comboMult(n)
|
||||
if n <= 10 then return 1.1 end
|
||||
if n <= 20 then return 1.5 end
|
||||
if n <= 30 then return 2.0 end
|
||||
if n <= 40 then return 2.5 end
|
||||
return 3.0
|
||||
end
|
||||
|
||||
-- the catch combo, persisted with the save (mod.save rides save.modData):
|
||||
-- catching the same species again extends it, anything else restarts it
|
||||
local function bumpCombo(species)
|
||||
local ms = V.mod and V.mod.save
|
||||
local combo = { species = species, count = 1 }
|
||||
if ms then
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
if ok and type(held) == "table" and held.species == species then
|
||||
combo.count = (tonumber(held.count) or 0) + 1
|
||||
end
|
||||
pcall(ms.set, ms, "letsgoCombo", combo)
|
||||
end
|
||||
return combo.count
|
||||
end
|
||||
|
||||
function LetsGo.combo()
|
||||
local ms = V.mod and V.mod.save
|
||||
if not ms then return nil end
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
return ok and type(held) == "table" and held or nil
|
||||
end
|
||||
|
||||
-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
|
||||
-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
|
||||
-- still answerable, and the exp hooks fire inside storeCaughtMon.
|
||||
function LetsGo.noteCatch(battle, info)
|
||||
local species = battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.species
|
||||
local chain = species and bumpCombo(species) or 1
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
local mult = info.mult or 1
|
||||
if info.firstThrow then mult = mult * 1.5 end
|
||||
local dex = game().save and game().save.pokedex
|
||||
if dex and species and not dex.owned[species] then mult = mult * 1.1 end
|
||||
mult = mult * comboMult(chain)
|
||||
expCtx = { battle = battle, mult = mult }
|
||||
end
|
||||
|
||||
-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
|
||||
-- level, +1, then the traded boost and (for a catch) the bonus stack.
|
||||
-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
|
||||
-- full share rather than a share of one.
|
||||
--
|
||||
-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
|
||||
-- absent from the catch-side write-ups of this formula for the simple
|
||||
-- reason that a caught Pokemon is always wild, so it is always 1 there --
|
||||
-- which is also why adding it leaves every catch payout exactly where it
|
||||
-- was, verified against the published table in the suite.
|
||||
local function scaledGain(c, mult)
|
||||
local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
|
||||
local L = c.level or 1
|
||||
local Lp = (c.mon and c.mon.level) or L
|
||||
local a = c.isTrainer and 1.5 or 1
|
||||
local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
|
||||
local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
|
||||
if c.traded then exp = math.floor(exp * 1.5) end
|
||||
return math.max(1, math.floor(exp * (mult or 1)))
|
||||
end
|
||||
|
||||
LetsGo._scaledGain = scaledGain -- named for the suite
|
||||
LetsGo._comboMult = comboMult
|
||||
|
||||
-- ------- FULL's auto-entry
|
||||
--
|
||||
-- The moment a wild battle's menu opens under FULL, capture mode opens
|
||||
-- over it, with the last ball the player threw (or the first ball in the
|
||||
-- bag). B backs out to the classic menu and stays out for that battle --
|
||||
-- the bag's own ball route still re-enters the throw.
|
||||
local function autoEnter()
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
if CatchThrow.active() then return end
|
||||
local ok, battle = pcall(function()
|
||||
return V.require("OverworldBattle").battle()
|
||||
end)
|
||||
if not (ok and battle) then return end
|
||||
local g = game()
|
||||
if not (g.stack and g.stack:top() == battle) then return end
|
||||
if battle.phase ~= "menu" then return end
|
||||
if battle.safari then return end -- the safari menu is already a
|
||||
-- catch menu; its BALL row enters
|
||||
if battle.dramaticShapeDeclined then return end
|
||||
if not LetsGo.wantsMinigame(battle) then return end
|
||||
local ball = CatchThrow.pickBall()
|
||||
local full = LetsGo.fullWild(battle)
|
||||
-- An empty bag does NOT fall back to the classic menu under FULL. A
|
||||
-- Let's Go wild has no player Pokemon in it and a foe that never takes a
|
||||
-- turn, so the menu it would fall back to offers a FIGHT that cannot
|
||||
-- happen -- the encounter has to keep its own screen and its own exit.
|
||||
-- The capture screen opens empty-handed instead: the foe stands there,
|
||||
-- the readout says there is nothing to throw, and RUN is the way out.
|
||||
-- At CATCH ONLY there is no auto-entry to speak of and the bag is the
|
||||
-- only route in, so no balls simply means no throw, as it always did.
|
||||
if not (ball or full) then return end
|
||||
CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
|
||||
fullWild = full })
|
||||
end
|
||||
|
||||
-- ------- per frame, from the voxel pipeline's update hook
|
||||
--
|
||||
-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
|
||||
-- frame computes is the ball the scene render a moment later draws.
|
||||
function LetsGo.update(dt)
|
||||
applyPrices()
|
||||
CatchThrow.update(dt)
|
||||
autoEnter()
|
||||
end
|
||||
|
||||
-- ------- install: the two throw seams, the hooks, the input
|
||||
--
|
||||
-- Installed from main.lua AFTER every other input seam, so the capture's
|
||||
-- pointer wraps sit outside them all while it aims.
|
||||
local installed = false
|
||||
|
||||
function LetsGo.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local mod = V.mod
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
if not BattleState.dramaticShapeLetsGoHook then
|
||||
-- The bag's ball route: BagMenu has already consumed the ball and
|
||||
-- closed itself when this is called, so a session here owns a paid
|
||||
-- ball (cancel refunds it). Vanilla path untouched whenever the
|
||||
-- minigame cannot or should not run.
|
||||
local innerThrow = BattleState.throwBall
|
||||
function BattleState:throwBall(ball)
|
||||
if LetsGo.wantsMinigame(self) then
|
||||
if CatchThrow.begin(self, ball, {
|
||||
consumed = true, fullWild = LetsGo.fullWild(self),
|
||||
}) then return end
|
||||
end
|
||||
return innerThrow(self, ball)
|
||||
end
|
||||
|
||||
-- The safari menu's BALL row: same interception, safari flavour --
|
||||
-- the ball count and the flee check belong to the safari turn, and
|
||||
-- CatchThrow hands back to safariEnemyTurn on a failure.
|
||||
local innerSafari = BattleState.safariAction
|
||||
function BattleState:safariAction(choice)
|
||||
if choice == "ball" and LetsGo.wantsMinigame(self)
|
||||
and self.safari and self.safari.balls > 0 then
|
||||
if CatchThrow.begin(self, "SAFARI_BALL",
|
||||
{ consumed = false, safari = true }) then
|
||||
return
|
||||
end
|
||||
end
|
||||
return innerSafari(self, choice)
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeLetsGoHook = true
|
||||
end
|
||||
|
||||
-- a catch pays experience under FULL, exactly as a knockout would
|
||||
-- Never for a scripted demo: the old man's catch is a cutscene, nobody
|
||||
-- keeps the Pokemon, and the party it would pay may not exist yet
|
||||
-- (Yellow's Pallet intro runs before the lab gift). The engine's own
|
||||
-- flow does not reach either hook for a demo today -- oldManThrow ends
|
||||
-- the battle without storeCaughtMon or awardExp -- so this guards the
|
||||
-- INVARIANT rather than a live bug: a demo pays nothing, whatever route
|
||||
-- some later engine takes to get there.
|
||||
mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle
|
||||
and ctx.battle.kind == "wild"
|
||||
and not LetsGo.scripted(ctx.battle) then
|
||||
return true
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- ------- the Let's Go distribution: every healthy party member, in full
|
||||
--
|
||||
-- The engine's own rule is that only the Pokemon that FOUGHT are paid,
|
||||
-- and they split one award between them; EXP.ALL exists to soften that.
|
||||
-- Let's Go deletes the whole arrangement -- everybody gains from
|
||||
-- everything, which is why that game ships no EXP.ALL at all -- and
|
||||
-- each one is measured against its OWN level, so the low member of a
|
||||
-- party pulls several times what the high one does from the same
|
||||
-- knockout.
|
||||
--
|
||||
-- Two ways in. A CATCH arrives with a bonus stack attached (throw
|
||||
-- grade, first ball, new species, combo) which `expCtx` carries. A
|
||||
-- KNOCKOUT under FULL takes the same distribution with no stack --
|
||||
-- those bonuses are rewards for the throw, and there was no throw.
|
||||
--
|
||||
-- Everything else -- CATCH ONLY, the row switched off, another mod's
|
||||
-- battle -- falls through to the engine's own split untouched.
|
||||
-- ------- and it is announced ONCE, not once per Pokemon
|
||||
--
|
||||
-- Six party members would otherwise mean six "X gained N EXP. Points!"
|
||||
-- boxes per knockout. The per-Pokemon lines are suppressed (the `false`
|
||||
-- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua
|
||||
-- for why that is better than a faster wall of the same text.
|
||||
--
|
||||
-- The card is queued BEFORE the loop that fills it. That is not a race:
|
||||
-- applyShare applies its experience immediately and only QUEUES its
|
||||
-- messages, so the loop runs to completion synchronously here, while
|
||||
-- the queue does not reach the card's factory until later -- by which
|
||||
-- time `rows` is complete. Queueing it first is what puts the tally
|
||||
-- ahead of the "grew to level" chatter it is a summary of.
|
||||
local ExpPanel = V.require("ExpPanel")
|
||||
local function payParty(ctx, mult)
|
||||
local battle = ctx.battle
|
||||
local rows = {}
|
||||
battle:uiNext(function() return ExpPanel.new(battle.game, rows) end)
|
||||
granting = { mult = mult }
|
||||
local okAward, err = pcall(function()
|
||||
for _, mon in ipairs(battle.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
local exp0, lv0 = mon.exp, mon.level
|
||||
ctx.applyShare(mon, 1, false)
|
||||
rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0,
|
||||
from = lv0, to = mon.level }
|
||||
end
|
||||
end
|
||||
end)
|
||||
granting = nil
|
||||
if not okAward then error(err, 0) end
|
||||
end
|
||||
|
||||
mod.hooks:wrap("battle.exp_award", function(next_, ctx)
|
||||
if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end
|
||||
local cc = expCtx
|
||||
if cc and ctx and ctx.battle == cc.battle then
|
||||
expCtx = nil
|
||||
return payParty(ctx, cc.mult)
|
||||
end
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle then
|
||||
return payParty(ctx, 1)
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- and the amount, per receiving mon, while that loop runs
|
||||
mod.hooks:wrap("exp.gain", function(next_, c)
|
||||
if not granting then return next_(c) end
|
||||
return scaledGain(c, granting.mult)
|
||||
end)
|
||||
|
||||
-- ------- FULL owns a wild encounter from its first frame
|
||||
--
|
||||
-- The engine's intro ends by sending the player's Pokemon out -- the
|
||||
-- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
|
||||
-- Go wild has no player Pokemon in it at all. The send-out is exactly
|
||||
-- the LAST SIX rows of the intro queue when this event fires (built in
|
||||
-- BattleState's start, gated `not safari and not demo`), so they are
|
||||
-- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
|
||||
-- if a future engine moves them: the veil still hides the visuals, the
|
||||
-- engine just narrates a send-out that is not shown.
|
||||
--
|
||||
-- Stripping them leaves showPlayerBack TRUE for the whole battle, which
|
||||
-- is the flag the engine's own HUD path reads as "no player HUD" -- the
|
||||
-- player's side vanishes from the readout for free.
|
||||
--
|
||||
-- The veil goes up in the same breath: the capture table, installed
|
||||
-- before any session exists, so the whole encounter -- wipe, "Wild X
|
||||
-- appeared!", every beat between throws -- plays from the held head-on
|
||||
-- seat with the player's side out of the shot.
|
||||
mod.events:on("battle.started", function(payload)
|
||||
local b = payload and payload.battle
|
||||
if not (b and LetsGo.fullWild(b)) then return end
|
||||
if not (Voxel3D.available() and not vrOn()) then return end
|
||||
-- deliberately NOT gated on owning a ball: an empty bag still gets the
|
||||
-- Let's Go encounter (see autoEnter), so the send-out still has to go
|
||||
local q = b.queue
|
||||
local n = q and #q or 0
|
||||
if n >= 6 and type(q[n]) == "table" and q[n].fn
|
||||
and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
|
||||
and q[n - 2] and q[n - 2].text
|
||||
and q[n - 3] and q[n - 3].fn
|
||||
and q[n - 4] and q[n - 4].wait
|
||||
and q[n - 5] and q[n - 5].fn then
|
||||
for _ = 1, 6 do table.remove(q) end
|
||||
end
|
||||
pcall(CatchThrow.veil, b)
|
||||
end)
|
||||
|
||||
-- a battle ending sweeps everything: the capture epilogue, the veil, a
|
||||
-- session a script tore down, and the exp context if the payout never
|
||||
-- fired
|
||||
mod.events:on("battle.ended", function()
|
||||
expCtx = nil
|
||||
pcall(CatchThrow.onBattleEnded)
|
||||
end)
|
||||
|
||||
CatchThrow.installInput()
|
||||
end
|
||||
|
||||
return LetsGo
|
||||
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
function Mat4.rotateZ(a)
|
||||
local c, s = math.cos(a), math.sin(a)
|
||||
return { c, -s, 0, 0,
|
||||
s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
-- The rotation a unit quaternion describes, row-major. The VR rig is what
|
||||
-- needs it: an OpenXR eye pose arrives as position + orientation
|
||||
-- quaternion, and both the eye's transform and its inverse (the view) are
|
||||
|
||||
+10
-6
@@ -168,6 +168,15 @@ function ModSetting:sync(value)
|
||||
self.index = indexOf(self, value)
|
||||
end
|
||||
|
||||
-- The label of the rung actually in force, which is not the stored one when
|
||||
-- that rung has been gated away (see get). Its own entry point because a
|
||||
-- caller can want the label without wanting a row: SettingsMenu puts one
|
||||
-- setting's rung on the second line of the CATEGORY that contains it.
|
||||
function ModSetting:valueLabel()
|
||||
local i = self:read()
|
||||
return self.labels[self:allows(i) and i or 1]
|
||||
end
|
||||
|
||||
-- The descriptor src/ui/OptionRows.lua renders, in the shape the
|
||||
-- ui.options.rows hook appends.
|
||||
function ModSetting:row()
|
||||
@@ -175,12 +184,7 @@ function ModSetting:row()
|
||||
return {
|
||||
id = "DRAMATIC_SHAPE:" .. self.key,
|
||||
label = self.label,
|
||||
-- the label of the rung actually in force, which is not the stored one
|
||||
-- when that rung has been gated away (see get)
|
||||
value = function()
|
||||
local i = self_:read()
|
||||
return self_.labels[self_:allows(i) and i or 1]
|
||||
end,
|
||||
value = function() return self_:valueLabel() end,
|
||||
step = function(game, dir)
|
||||
self_:cycle(game, dir)
|
||||
return true
|
||||
|
||||
+131
-28
@@ -355,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
|
||||
@@ -594,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
|
||||
@@ -615,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
|
||||
@@ -698,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))
|
||||
@@ -751,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
|
||||
@@ -984,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
|
||||
@@ -1056,6 +1101,14 @@ function OverworldBattle.sideTexture(battle, side)
|
||||
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
|
||||
texturing = side
|
||||
|
||||
-- ------- no shiny tint here any more
|
||||
--
|
||||
-- This used to bracket the draw below with that side's shiny tint, on the
|
||||
-- grounds that rendering one side at a time is the only place the two can
|
||||
-- be coloured differently. True, and no longer needed: the PIC itself is
|
||||
-- now built from a shiny palette (lib/ShinyPics.lua), which is per-mon
|
||||
-- rather than per-side and gets the colour right instead of approximating
|
||||
-- it with a multiply. Tinting on top of that would apply the shift twice.
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
@@ -1107,7 +1160,12 @@ 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
|
||||
@@ -1207,6 +1265,22 @@ function OverworldBattle.install()
|
||||
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
|
||||
end
|
||||
|
||||
-- ------- the shiny arrival sparkle, on every rung this file draws
|
||||
--
|
||||
-- Called from BOTH branches below, because both are a complete battle
|
||||
-- frame: the `not shot` branch is the engine's own screen (3D-BTL OFF, and
|
||||
-- any battle the mod does not stage), and the other is the staged shot.
|
||||
--
|
||||
-- It lives here rather than on a hook or a monkeypatch of its own because
|
||||
-- this override IS the battle's draw -- every rung, every frame. The two
|
||||
-- other seams were tried and measured at zero calls: BattleState:update is
|
||||
-- never reached (the battle is not the top of the stack during its own
|
||||
-- intro), and the engine's `battle.overlay` hook is only reached through
|
||||
-- the tail of the engine's draw. See lib/ShinyFlash.lua.
|
||||
local function shinyFlash(battle)
|
||||
pcall(function() V.require("ShinyFlash").render(battle) end)
|
||||
end
|
||||
|
||||
local innerDraw = BattleState.draw
|
||||
function BattleState:draw()
|
||||
local shot = OverworldBattle.shot()
|
||||
@@ -1217,7 +1291,9 @@ function OverworldBattle.install()
|
||||
-- that loses its arena mid-fight goes back to white voids
|
||||
self.letterboxWhite = nil
|
||||
self.dramaticShapeShot = nil
|
||||
return innerDraw(self)
|
||||
local out = innerDraw(self)
|
||||
shinyFlash(self)
|
||||
return out
|
||||
end
|
||||
self.dramaticShapeShot = shot
|
||||
-- The world reaches the screen through the seam a render pipeline's
|
||||
@@ -1239,6 +1315,12 @@ 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
|
||||
shinyFlash(self)
|
||||
end
|
||||
|
||||
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
||||
@@ -1256,6 +1338,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.
|
||||
@@ -1278,7 +1364,13 @@ function OverworldBattle.install()
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
if isIOS() then return innerText(self) end
|
||||
-- While a capture session is being AIMED the box is empty -- the
|
||||
-- battle's phase is parked, so there is no message in it -- and it
|
||||
-- covers the bottom third of the frame, which is exactly the room a
|
||||
-- throw needs to wind up in. So it comes off entirely for those
|
||||
-- frames and is back the instant a message has something to say.
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hideTextBox then return end
|
||||
return withoutBoxFill(self, innerText)
|
||||
end
|
||||
|
||||
@@ -1451,13 +1543,18 @@ 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
|
||||
-- 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
|
||||
@@ -1473,8 +1570,24 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
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
|
||||
@@ -1494,16 +1607,6 @@ end
|
||||
function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
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)
|
||||
return
|
||||
end
|
||||
if snapped() then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws.
|
||||
--
|
||||
-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D
|
||||
-- sprite inside the battle's move-animation layer. This is a ball that can
|
||||
-- fly through the arena, hang in the air in front of the camera, hinge its
|
||||
-- lid open, drink a Pokemon in, click shut, rock on the ground and burst
|
||||
-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like
|
||||
-- everything else in the diorama, because it is a mesh in Voxel3D's own
|
||||
-- format going through Voxel3D's own shader.
|
||||
--
|
||||
-- ------- how it is built
|
||||
--
|
||||
-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base
|
||||
-- and the coloured LID -- each carrying its half of the black band as a
|
||||
-- slightly bulged latitude belt, so the two halves separate exactly where
|
||||
-- the real ball separates. The button is a little cylinder standing out of
|
||||
-- the base's front; the interior is sealed with two pale discs so an open
|
||||
-- ball shows a shell with a floor rather than a view through to the far
|
||||
-- wall's backface. Colour is a palette texture one texel per material and
|
||||
-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the
|
||||
-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow
|
||||
-- band without a second mesh, just a different V coordinate.
|
||||
--
|
||||
-- Shade is baked per vertex from the surface normal with StadiumStage's
|
||||
-- fitted constants, which is this mod's answer for anything curved: the
|
||||
-- ball's sun side and belly read as a sphere under the same southeastern
|
||||
-- sun the roofs are lit by.
|
||||
--
|
||||
-- ------- how it animates
|
||||
--
|
||||
-- The HordeGun way: a handful of scalar timers advanced by update(dt) and
|
||||
-- consumed as matrix terms at draw time. No skeleton, no keyframes --
|
||||
-- lid is a hinge matrix about the back of the equator, the wobble is a
|
||||
-- decaying rotateZ about the ground contact point, the caught click is a
|
||||
-- squash pulse, the stars are one shared quad drawn a few times facing the
|
||||
-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop)
|
||||
-- is the caller's problem, which is what keeps this file a prop and not a
|
||||
-- game mode.
|
||||
--
|
||||
-- Nothing here touches love.* until something has to be drawn, so the
|
||||
-- module loads and the state machine runs headless -- the test suite
|
||||
-- exercises the phases without a GPU.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
|
||||
local Pokeball = {}
|
||||
Pokeball.__index = Pokeball
|
||||
|
||||
-- ------- the ball's measurements, in world pixels
|
||||
--
|
||||
-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball
|
||||
-- sits in the hand and against a Pokemon at about the proportion the games
|
||||
-- draw: unmissable in the foreground, believable at the far cell.
|
||||
Pokeball.R = 2.2
|
||||
|
||||
-- the black belt: half-height as a latitude angle, and how far the belt
|
||||
-- bulges past the shell so it reads as a band and not a painted stripe
|
||||
local BAND_LAT = 0.16
|
||||
local BAND_R = 1.045
|
||||
|
||||
-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is
|
||||
-- past upright -- the mouth gapes at the sky, which is the capture pose.
|
||||
local HINGE_Z = -0.86 -- as a fraction of R
|
||||
local LID_OPEN = 2.0
|
||||
|
||||
-- tessellation: enough that the silhouette is round at held-ball size,
|
||||
-- cheap enough that six of these would not show on a phone's frame budget
|
||||
local LON = 14
|
||||
local LAT = 5
|
||||
|
||||
-- pose timing
|
||||
local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second
|
||||
local BURST_RATE = 14 -- the breakout pop is a violent open
|
||||
local WOBBLE_T = 0.85 -- one rock, seconds
|
||||
local WOBBLE_A = 0.38 -- how far it tips, radians
|
||||
local PULSE_T = 0.14 -- the caught click's squash pulse
|
||||
local STAR_T = 0.9 -- the caught stars' life
|
||||
local GLOW_DECAY = 2.2 -- additive glow, per second
|
||||
|
||||
-- ------- palette
|
||||
--
|
||||
-- One texel per material (columns), one row per ball tier. Alpha stays 1
|
||||
-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a
|
||||
-- translucent texel is an invisible one.
|
||||
local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5,
|
||||
INNER = 6, GLOW = 7, STAR = 8 }
|
||||
local SLOT_N = 8
|
||||
|
||||
local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL",
|
||||
"SAFARI_BALL" }
|
||||
|
||||
local COLORS = {
|
||||
POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 },
|
||||
bottom = { 0.93, 0.93, 0.95 },
|
||||
glow = { 1.0, 0.72, 0.92 } },
|
||||
SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 },
|
||||
bottom = { 0.90, 0.88, 0.80 } },
|
||||
}
|
||||
|
||||
local SHARED = {
|
||||
ring = { 0.28, 0.28, 0.30 },
|
||||
face = { 0.96, 0.96, 0.97 },
|
||||
inner = { 0.72, 0.70, 0.68 },
|
||||
glow = { 1.00, 0.92, 0.65 },
|
||||
star = { 1.00, 0.85, 0.25 },
|
||||
}
|
||||
|
||||
local function tierRow(ball)
|
||||
for i, id in ipairs(TIERS) do
|
||||
if id == ball then return i end
|
||||
end
|
||||
return 1 -- an unknown ball is a plain POKE BALL
|
||||
end
|
||||
|
||||
-- palette texel centres
|
||||
local function uvFor(slot, row)
|
||||
return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS
|
||||
end
|
||||
|
||||
local palette = nil
|
||||
local function paletteTexture()
|
||||
if palette ~= nil then return palette or nil end
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(SLOT_N, #TIERS)
|
||||
for row, id in ipairs(TIERS) do
|
||||
local c = COLORS[id]
|
||||
local function put(slot, rgb)
|
||||
data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1)
|
||||
end
|
||||
put(SLOTS.TOP, c.top)
|
||||
put(SLOTS.BOTTOM, c.bottom)
|
||||
put(SLOTS.BAND, c.band)
|
||||
put(SLOTS.RING, SHARED.ring)
|
||||
put(SLOTS.FACE, SHARED.face)
|
||||
put(SLOTS.INNER, SHARED.inner)
|
||||
put(SLOTS.GLOW, c.glow or SHARED.glow)
|
||||
put(SLOTS.STAR, SHARED.star)
|
||||
end
|
||||
local tex = love.graphics.newImage(data)
|
||||
tex:setFilter("nearest", "nearest")
|
||||
return tex
|
||||
end)
|
||||
palette = ok and img or false
|
||||
return palette or nil
|
||||
end
|
||||
|
||||
-- ------- shade
|
||||
--
|
||||
-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern
|
||||
-- sun, answered for an arbitrary normal instead of one of six faces.
|
||||
local function shadeFor(nx, ny, nz)
|
||||
local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11
|
||||
return math.max(0.30, math.min(1.00, s))
|
||||
end
|
||||
|
||||
-- ------- mesh building
|
||||
--
|
||||
-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices.
|
||||
-- Quads go through the shared corner order; the discs use a degenerate
|
||||
-- fourth vertex, which the rasteriser drops as the zero-area triangle it is.
|
||||
local function quad(verts, map, a, b, c, d)
|
||||
local n = #verts
|
||||
verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d
|
||||
Voxel3D.pushQuad(map, n / 4)
|
||||
end
|
||||
|
||||
local R = Pokeball.R
|
||||
local TAU = math.pi * 2
|
||||
|
||||
-- a latitude zone of the sphere between phi0 and phi1 (radians from the
|
||||
-- equator, north positive), at radiusK times the shell radius
|
||||
local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK)
|
||||
local u, v = uvFor(slot, row)
|
||||
local r = R * (radiusK or 1)
|
||||
for i = 0, rows - 1 do
|
||||
local pa = phi0 + (phi1 - phi0) * (i / rows)
|
||||
local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows)
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local function corner(phi, th)
|
||||
local nx = math.cos(phi) * math.sin(th)
|
||||
local ny = math.sin(phi)
|
||||
local nz = math.cos(phi) * math.cos(th)
|
||||
return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) }
|
||||
end
|
||||
quad(verts, map, corner(pa, ta), corner(pa, tb),
|
||||
corner(pb, tb), corner(pb, ta))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- a disc in a y-plane, sealed with fan quads about the centre
|
||||
local function disc(verts, map, y, radius, slot, row, up)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, up and 1 or -1, 0)
|
||||
local centre = { 0, y, 0, u, v, sh }
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh }
|
||||
local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
|
||||
-- the button: a ring wall and its face, standing out of the shell along +Z
|
||||
local function button(verts, map, row)
|
||||
local BLON = 10
|
||||
local function ringWall(rad, z0, z1, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local function at(th, z)
|
||||
local nx, ny = math.cos(th), math.sin(th)
|
||||
return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) }
|
||||
end
|
||||
quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1))
|
||||
end
|
||||
end
|
||||
local function faceDisc(rad, z, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, 0, 1)
|
||||
local centre = { 0, 0, z, u, v, sh }
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh }
|
||||
local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
-- the wall starts inside the shell so the junction never shows a gap
|
||||
ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING)
|
||||
faceDisc(0.75, R + 0.30, SLOTS.RING)
|
||||
ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING)
|
||||
faceDisc(0.45, R + 0.42, SLOTS.FACE)
|
||||
end
|
||||
|
||||
-- one tier's meshes, memoised: { base = , lid = , spark = }
|
||||
--
|
||||
-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel;
|
||||
-- the glow disc, the beam and every star are that card under a matrix.
|
||||
local meshes = {}
|
||||
local function meshesFor(ball)
|
||||
local row = tierRow(ball)
|
||||
local hit = meshes[row]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local ok, built = pcall(function()
|
||||
local bv, bm = {}, {}
|
||||
-- the base: white bowl from the south pole up to the band, its half of
|
||||
-- the band, the interior floor and the button on the front
|
||||
zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row)
|
||||
zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R)
|
||||
disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true)
|
||||
button(bv, bm, row)
|
||||
|
||||
local lv, lm = {}, {}
|
||||
-- the lid: its half of the band up to the coloured dome, and its pale
|
||||
-- underside, which is what shows once the hinge tips it back
|
||||
zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R)
|
||||
zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row)
|
||||
disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false)
|
||||
|
||||
local base = Voxel3D.newMesh(bv, bm)
|
||||
local lid = Voxel3D.newMesh(lv, lm)
|
||||
if not (base and lid) then return nil end
|
||||
|
||||
local function card(slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local cv, cm = {}, {}
|
||||
quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 },
|
||||
{ 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 })
|
||||
return Voxel3D.newMesh(cv, cm)
|
||||
end
|
||||
return { base = base, lid = lid,
|
||||
glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) }
|
||||
end)
|
||||
meshes[row] = (ok and built) or false
|
||||
return meshes[row] or nil
|
||||
end
|
||||
|
||||
-- dropped so a lost GL context (Android resume) rebuilds everything
|
||||
function Pokeball.invalidate()
|
||||
meshes = {}
|
||||
palette = nil
|
||||
end
|
||||
|
||||
-- ------- an instance: one ball with a pose
|
||||
--
|
||||
-- Loads and runs without graphics; only draw() and cast() want a GPU.
|
||||
function Pokeball.new(ball)
|
||||
return setmetatable({
|
||||
ball = ball or "POKE_BALL",
|
||||
pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE
|
||||
yaw = 0, -- which way the button faces
|
||||
scale = 1,
|
||||
spin = 0, -- visual spin about the vertical, rad/s
|
||||
tumble = 0, -- end-over-end in flight, rad/s
|
||||
roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about
|
||||
-- the axis out of the ball's face, which
|
||||
-- with the yaw at the camera reads as the
|
||||
-- ball turning clockwise/counter-clockwise
|
||||
-- to the viewer -- the curveball wind-up
|
||||
spinAngle = 0, tumbleAngle = 0, rollAngle = 0,
|
||||
lid = 0, lidTarget = 0, lidRate = LID_RATE,
|
||||
wobbleT = nil, wobbleDir = 1,
|
||||
pulse = nil, -- the caught click's squash
|
||||
glow = 0,
|
||||
stars = nil, -- caught celebration, or nil
|
||||
visible = true,
|
||||
}, Pokeball)
|
||||
end
|
||||
|
||||
-- ------- the verbs the capture flow speaks
|
||||
|
||||
function Pokeball:open()
|
||||
self.lidTarget, self.lidRate = 1, LID_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:close()
|
||||
self.lidTarget, self.lidRate = 0, LID_RATE
|
||||
end
|
||||
|
||||
-- one rock on the ground; dir alternates shakes. Returns how long it takes,
|
||||
-- so the caller can sequence the pauses between shakes.
|
||||
function Pokeball:rock(dir)
|
||||
self.wobbleT = 0
|
||||
self.wobbleDir = dir or 1
|
||||
return WOBBLE_T
|
||||
end
|
||||
|
||||
-- the caught click: squash pulse, a soft flash, and the stars
|
||||
function Pokeball:catchClick()
|
||||
self.pulse = 0
|
||||
self.glow = 0.6
|
||||
local stars = {}
|
||||
for i = 1, 6 do
|
||||
stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 }
|
||||
end
|
||||
self.stars = stars
|
||||
end
|
||||
|
||||
-- the breakout: the lid blown open and a hard flash
|
||||
function Pokeball:burst()
|
||||
self.lidTarget, self.lidRate = 1, BURST_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:busy()
|
||||
return self.wobbleT ~= nil or self.pulse ~= nil
|
||||
or math.abs(self.lid - self.lidTarget) > 0.02
|
||||
end
|
||||
|
||||
function Pokeball:update(dt)
|
||||
-- lid toward its target, at whatever violence was asked for
|
||||
local d = self.lidTarget - self.lid
|
||||
if d ~= 0 then
|
||||
local step = self.lidRate * dt
|
||||
if math.abs(d) <= step then
|
||||
-- arriving CLOSED from open is the shut click: the squash pulse
|
||||
if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end
|
||||
self.lid = self.lidTarget
|
||||
else
|
||||
self.lid = self.lid + (d > 0 and step or -step)
|
||||
end
|
||||
end
|
||||
if self.wobbleT then
|
||||
self.wobbleT = self.wobbleT + dt
|
||||
if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end
|
||||
end
|
||||
if self.pulse then
|
||||
self.pulse = self.pulse + dt
|
||||
if self.pulse >= PULSE_T then self.pulse = nil end
|
||||
end
|
||||
if self.stars then
|
||||
local live = false
|
||||
for _, s in ipairs(self.stars) do
|
||||
s.t = s.t + dt
|
||||
if s.t < STAR_T then live = true end
|
||||
end
|
||||
if not live then self.stars = nil end
|
||||
end
|
||||
self.glow = math.max(0, self.glow - GLOW_DECAY * dt)
|
||||
self.spinAngle = self.spinAngle + self.spin * dt
|
||||
self.tumbleAngle = self.tumbleAngle + self.tumble * dt
|
||||
self.rollAngle = self.rollAngle + self.roll * dt
|
||||
end
|
||||
|
||||
-- ------- pose as a matrix
|
||||
|
||||
local function smooth(t)
|
||||
if t <= 0 then return 0 end
|
||||
if t >= 1 then return 1 end
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
function Pokeball:matrix()
|
||||
local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]),
|
||||
Mat4.rotateY(self.yaw))
|
||||
if self.wobbleT then
|
||||
-- a decaying rock about the ground contact: tip, cross through centre,
|
||||
-- tip the other way, settle
|
||||
local t = self.wobbleT / WOBBLE_T
|
||||
local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir
|
||||
m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0),
|
||||
Mat4.mul(Mat4.rotateZ(a),
|
||||
Mat4.translate(0, R * self.scale, 0))))
|
||||
end
|
||||
if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end
|
||||
if self.tumbleAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle))
|
||||
end
|
||||
-- the roll turns about the ball's own face axis, so with the yaw aimed
|
||||
-- at the camera it reads as clockwise/counter-clockwise on screen
|
||||
if self.rollAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle))
|
||||
end
|
||||
local k = self.scale
|
||||
if self.pulse then
|
||||
-- the click: a quick squash and back, more felt than seen
|
||||
local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14
|
||||
m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p)))
|
||||
elseif k ~= 1 then
|
||||
m = Mat4.mul(m, Mat4.scale(k, k, k))
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
-- the hinge: the lid's own extra transform about the back of the equator
|
||||
local function lidMatrix(open)
|
||||
if open <= 0 then return nil end
|
||||
local a = -LID_OPEN * smooth(open)
|
||||
local hz = HINGE_Z * R
|
||||
return Mat4.mul(Mat4.translate(0, 0, hz),
|
||||
Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz)))
|
||||
end
|
||||
|
||||
-- where the open mouth is, for aiming the capture beam
|
||||
function Pokeball:mouth()
|
||||
return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3]
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly
|
||||
-- like Stadium.draw. Seams and glass are off for the duration: the ball is
|
||||
-- not on the voxel grid and does not wear the tileset atlas.
|
||||
local function eyeYaw(x, z)
|
||||
local eye = Voxel3D.eye
|
||||
if not eye then return 0 end
|
||||
return math.atan2(eye[1] - x, eye[3] - z)
|
||||
end
|
||||
|
||||
function Pokeball:draw(pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
local model = self:matrix()
|
||||
Voxel3D.draw(m.base, pal, model, pull)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull)
|
||||
|
||||
-- the additive dressing: the open-mouth glow and the caught stars.
|
||||
-- Depth writes are off under "add" (Voxel3D.blend), so these can never
|
||||
-- punch holes for later draws.
|
||||
local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars
|
||||
if anythingAdd then
|
||||
Voxel3D.blend("add")
|
||||
if self.glow > 0.05 and self.lid > 0.1 then
|
||||
-- a pulsing octahedron of light standing in the mouth: two crossed
|
||||
-- cards read from every seat in the house
|
||||
local gx, gy, gz = self:mouth()
|
||||
local s = R * (1.1 + 0.25 * self.glow) * self.scale
|
||||
for i = 0, 1 do
|
||||
local card = Mat4.mul(Mat4.translate(gx, gy, gz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2),
|
||||
Mat4.scale(s, s, 1)))
|
||||
Voxel3D.draw(m.glow, pal, card, pull)
|
||||
end
|
||||
end
|
||||
if self.stars then
|
||||
for _, s in ipairs(self.stars) do
|
||||
if s.t > 0 and s.t < STAR_T then
|
||||
local t = s.t / STAR_T
|
||||
local rr = (R + 4.5 * t) * self.scale
|
||||
local sx = self.pos[1] + math.sin(s.th) * rr
|
||||
local sz = self.pos[3] + math.cos(s.th) * rr
|
||||
local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale
|
||||
local sc = 1.1 * (1 - t)
|
||||
local card = Mat4.mul(Mat4.translate(sx, sy, sz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)),
|
||||
Mat4.mul(Mat4.rotateZ(TAU * t * 0.5),
|
||||
Mat4.scale(sc, sc, 1))))
|
||||
Voxel3D.draw(m.star, pal, card, pull)
|
||||
end
|
||||
end
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- the capture beam: a crossed pair of additive cards stretched from the
|
||||
-- ball's mouth to the mon it is drinking in. Separate from draw() because
|
||||
-- the caller owns the far end and the fade.
|
||||
function Pokeball:drawBeam(tx, ty, tz, width, strength, pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local x, y, z = self:mouth()
|
||||
local dx, dy, dz = tx - x, ty - y, tz - z
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len < 0.5 then return end
|
||||
dx, dy, dz = dx / len, dy / len, dz / len
|
||||
-- two perpendiculars to the beam axis
|
||||
local ux, uy, uz
|
||||
if math.abs(dy) < 0.94 then
|
||||
ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised
|
||||
local l = math.sqrt(ux * ux + uz * uz)
|
||||
ux, uz = ux / l, uz / l
|
||||
else
|
||||
ux, uy, uz = 1, 0, 0
|
||||
end
|
||||
local vx = dy * uz - dz * uy
|
||||
local vy = dz * ux - dx * uz
|
||||
local vz = dx * uy - dy * ux
|
||||
local w = (width or R) * (strength or 1)
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.blend("add")
|
||||
-- the unit card is x -0.5..0.5, y 0..1: columns map its x to a
|
||||
-- perpendicular and its y to the full run of the axis
|
||||
local a = { ux * w, dx * len, vx, x,
|
||||
uy * w, dy * len, vy, y,
|
||||
uz * w, dz * len, vz, z,
|
||||
0, 0, 0, 1 }
|
||||
local b = { vx * w, dx * len, ux, x,
|
||||
vy * w, dy * len, uy, y,
|
||||
vz * w, dz * len, uz, z,
|
||||
0, 0, 0, 1 }
|
||||
Voxel3D.draw(m.glow, pal, a, pull)
|
||||
Voxel3D.draw(m.glow, pal, b, pull)
|
||||
Voxel3D.blend(nil)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- ------- the sun's view
|
||||
--
|
||||
-- The same two shells under the same matrix, so the shadow on the ground is
|
||||
-- the pose the camera sees. The caller folds a term into the shadow
|
||||
-- signature while a ball is live (the sun pass is cached -- see
|
||||
-- BattleScene.shadowSignature) or this freezes on its first frame.
|
||||
function Pokeball:cast(shadowMap)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local model = self:matrix()
|
||||
shadowMap.draw(m.base, pal, model)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model)
|
||||
end
|
||||
|
||||
-- a term for the arena's cached shadow signature: quantised, so the cache
|
||||
-- only re-renders when the ball has visibly moved
|
||||
function Pokeball:signature()
|
||||
if not self.visible then return "" end
|
||||
return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4),
|
||||
math.floor(self.pos[3] * 4), math.floor(self.lid * 8),
|
||||
self.wobbleT and math.floor(self.wobbleT * 30) or -1 },
|
||||
",")
|
||||
end
|
||||
|
||||
return Pokeball
|
||||
@@ -0,0 +1,178 @@
|
||||
-- SELECT on a row explains what it does.
|
||||
--
|
||||
-- ------- why this exists at all
|
||||
--
|
||||
-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
|
||||
-- into the schema handed to the mod manager (ModSetting:schema takes it), it
|
||||
-- has been written and kept up to date beside every row in main.lua's
|
||||
-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
|
||||
-- menu, whose row is a label and a value and has no room for a third thing;
|
||||
-- not the mod manager's own page, which renders the same two lines. It was
|
||||
-- authored, structured, accurate prose sitting in a field with no reader.
|
||||
--
|
||||
-- So it gets one. A row on this mod's menus says what it IS on one line and
|
||||
-- what it is SET TO on the next, and SELECT says what that means -- which is
|
||||
-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
|
||||
-- characters however the label is worded.
|
||||
--
|
||||
-- SELECT rather than a button that already does something: A steps a setting,
|
||||
-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
|
||||
-- SELECT hotkey is installed on OverworldController:handleInput, which only
|
||||
-- runs while the overworld is the top state, so a menu can have the button
|
||||
-- without taking anything from the map.
|
||||
--
|
||||
-- ------- the shape of it
|
||||
--
|
||||
-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
|
||||
-- ROM's own glyphs and a mod-supplied font theme retextures this along with
|
||||
-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
|
||||
-- with the menu still visible above it, which is where this game has put
|
||||
-- every line of text anybody has ever read in it.
|
||||
--
|
||||
-- Sized to what it holds rather than to the screen. Each description is one
|
||||
-- sentence, so most of these are five or six tiles tall and the row being
|
||||
-- asked about is still on screen over the top of the box. A sentence long
|
||||
-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
|
||||
-- time on the d-pad -- which is a fallback, not the design: the answer to a
|
||||
-- description that needs scrolling is a shorter description.
|
||||
|
||||
-- the mod namespace (see main.lua)
|
||||
local V = ...
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SettingsHelp = {}
|
||||
SettingsHelp.__index = SettingsHelp
|
||||
|
||||
-- NOT opaque: the menu stays drawn underneath, so the row being asked about
|
||||
-- is still on screen above the box. That is most of why the box is only as
|
||||
-- tall as it needs to be.
|
||||
SettingsHelp.isOpaque = false
|
||||
|
||||
-- The box spans the screen's twenty tiles and its border owns the outer ring,
|
||||
-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
|
||||
-- is kept clear for the more-arrow, which would otherwise land on top of the
|
||||
-- last character of any line that filled the width.
|
||||
local COLS = 17
|
||||
local PEN_X = 8
|
||||
local SCREEN_ROWS = 18
|
||||
-- title, plus the body, plus the two border rows
|
||||
local CHROME_ROWS = 3
|
||||
-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
|
||||
-- characters, which is a long sentence and a box two thirds up the screen.
|
||||
local MAX_LINES = 8
|
||||
|
||||
-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
|
||||
-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
|
||||
-- show, and this one is the whole point of the screen.
|
||||
local function wrapped(str, cols)
|
||||
cols = cols or COLS
|
||||
local lines, line = {}, nil
|
||||
for word in tostring(str or ""):gmatch("%S+") do
|
||||
local try = line and (line .. " " .. word) or word
|
||||
if #try <= cols then
|
||||
line = try
|
||||
else
|
||||
if line then lines[#lines + 1] = line end
|
||||
-- a word longer than the line is broken across lines rather than cut;
|
||||
-- nothing in the help text is that long today, but losing the end of a
|
||||
-- sentence silently is not a failure mode worth leaving open
|
||||
while #word > cols do
|
||||
lines[#lines + 1] = word:sub(1, cols)
|
||||
word = word:sub(cols + 1)
|
||||
end
|
||||
line = word
|
||||
end
|
||||
end
|
||||
if line then lines[#lines + 1] = line end
|
||||
return lines
|
||||
end
|
||||
|
||||
SettingsHelp.wrapped = wrapped
|
||||
|
||||
function SettingsHelp.new(game, title, body)
|
||||
return setmetatable({
|
||||
game = game,
|
||||
title = tostring(title or ""):gsub("%.%.$", ""),
|
||||
lines = wrapped(body),
|
||||
top = 0,
|
||||
}, SettingsHelp)
|
||||
end
|
||||
|
||||
-- How many body lines this box shows: all of them, unless there are more than
|
||||
-- a box is allowed to be tall.
|
||||
function SettingsHelp:bodyRows()
|
||||
return math.min(#self.lines, MAX_LINES)
|
||||
end
|
||||
|
||||
function SettingsHelp:maxTop()
|
||||
return math.max(0, #self.lines - self:bodyRows())
|
||||
end
|
||||
|
||||
-- Every button that could mean "done" closes it, including SELECT itself --
|
||||
-- the press that opened the box is the one a player is most likely to reach
|
||||
-- for to get rid of it. A is in there too: it steps a setting everywhere else
|
||||
-- on these menus, and stepping one you cannot see would be worse than an
|
||||
-- extra way out.
|
||||
local DISMISS = { "a", "b", "start", "select" }
|
||||
|
||||
function SettingsHelp:update()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local maxTop = self:maxTop()
|
||||
-- the d-pad only does anything when there is something below the fold; a
|
||||
-- box showing its whole sentence has nowhere to go and says so by not
|
||||
-- moving
|
||||
if input:wasPressed("down") then
|
||||
self.top = math.min(maxTop, self.top + 1)
|
||||
return
|
||||
elseif input:wasPressed("up") then
|
||||
self.top = math.max(0, self.top - 1)
|
||||
return
|
||||
end
|
||||
for _, btn in ipairs(DISMISS) do
|
||||
if input:wasPressed(btn) then
|
||||
local stack = self.game.stack
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
if stack and stack:top() == self then stack:pop() end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SettingsHelp:draw()
|
||||
local body = self:bodyRows()
|
||||
local th = body + CHROME_ROWS
|
||||
local ty = SCREEN_ROWS - th -- anchored to the bottom of the screen
|
||||
Font.drawBox(0, ty, 20, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- the row's own name, so the box says what it is about even where it covers
|
||||
-- the row that was asked
|
||||
Font.draw(self.title, PEN_X, (ty + 1) * 8)
|
||||
for i = 1, body do
|
||||
local line = self.lines[self.top + i]
|
||||
if not line then break end
|
||||
Font.draw(line, PEN_X, (ty + 1 + i) * 8)
|
||||
end
|
||||
-- the same marker the options list uses for "there is more below this", so
|
||||
-- it means here what it means there
|
||||
if self.top < self:maxTop() then
|
||||
Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Game:draw stops at the first state that HAS this method, so without one the
|
||||
-- box would inherit whatever is underneath -- which is a menu of ours, whose
|
||||
-- answer happens to be right. Stated anyway: the reason that answer is right
|
||||
-- is not a property of this screen, and a future menu that paints something
|
||||
-- of its own would silently repaint this box with it.
|
||||
function SettingsHelp:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
return SettingsHelp
|
||||
@@ -0,0 +1,464 @@
|
||||
-- This mod's settings, in categories, on menus of their own.
|
||||
--
|
||||
-- ------- why the flat list had to end
|
||||
--
|
||||
-- Every setting used to be spliced straight into the engine's OPTIONS list,
|
||||
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
|
||||
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
|
||||
-- was four screens of scrolling inside a list that already carried twenty
|
||||
-- engine rows -- and a player looking for SHADOWS had to know it was in there
|
||||
-- somewhere, past the wireframe and the horizon bend.
|
||||
--
|
||||
-- The engine has no grouping to borrow: a row descriptor is
|
||||
-- { id, label, value, step, activate } and nothing else. No headers, no
|
||||
-- sections, no pages. What it DOES have is `activate`, and a state stack that
|
||||
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
|
||||
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
|
||||
--
|
||||
-- ------- how the split was chosen
|
||||
--
|
||||
-- Not invented here: the mod already sorted its own settings, in the `full`
|
||||
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
|
||||
-- take away, and the reasoning written next to each one is always the same
|
||||
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
|
||||
-- the diorama FULL is a preset for.
|
||||
--
|
||||
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
|
||||
-- case to disappear under FULL: every child filters itself out and the
|
||||
-- category goes with them (see rows). PERFORMANCE is the three rows marked
|
||||
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
|
||||
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
|
||||
-- are the two features that are not about the look at all.
|
||||
--
|
||||
-- ------- what did NOT change
|
||||
--
|
||||
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
|
||||
-- row id, so options.lua is byte-identical for a player who upgrades and
|
||||
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
|
||||
-- identity a setting has. The hotkeys are untouched too, which is what makes
|
||||
-- the nesting affordable: a buried row is still one keypress away.
|
||||
|
||||
-- the mod namespace (see main.lua)
|
||||
local V = ...
|
||||
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SettingsMenu = {}
|
||||
SettingsMenu.__index = SettingsMenu
|
||||
|
||||
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
|
||||
-- covered, so there is no reason to pay for drawing it.
|
||||
SettingsMenu.isOpaque = true
|
||||
|
||||
SettingsMenu.ROOT = "root"
|
||||
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
|
||||
|
||||
-- Row ids live in a namespace of their own -- "menu." rather than a setting
|
||||
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
|
||||
-- settings rows have carried since the beginning.
|
||||
function SettingsMenu.id(catId)
|
||||
return "DRAMATIC_SHAPE:menu." .. catId
|
||||
end
|
||||
|
||||
-- ------- the categories, in menu order
|
||||
--
|
||||
-- `summary` is the second line of the category's own row, the way MODS reads
|
||||
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
|
||||
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
|
||||
-- current rung, which is the thing a player actually wants to know without
|
||||
-- opening it. Where no single row speaks for the rest, it counts them, which
|
||||
-- is honest rather than arbitrary.
|
||||
SettingsMenu.CATEGORIES = {
|
||||
{ id = "world", label = "3D WORLD..",
|
||||
help = "The diorama itself: how far the world bends, how much of it is "
|
||||
.. "drawn, what the water does and what hour it is outdoors." },
|
||||
{ id = "battles", label = "BATTLES..",
|
||||
summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
|
||||
help = "What a fight is drawn over, how it is framed, and how a ball is "
|
||||
.. "thrown." },
|
||||
{ id = "perf", label = "PERFORMANCE..",
|
||||
help = "What the look costs -- the three most expensive things in the "
|
||||
.. "frame after the geometry itself." },
|
||||
{ id = "vr", label = "VR..",
|
||||
summary = function() return V.require("VR").setting:valueLabel() end,
|
||||
help = "PCVR through OpenXR, and the one comfort setting that belongs to "
|
||||
.. "the headset alone." },
|
||||
}
|
||||
|
||||
-- ------- help for the rows that are not settings
|
||||
--
|
||||
-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
|
||||
-- next to the row it explains. What is left is the two pipeline rows -- whose
|
||||
-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
|
||||
-- and the ROM import, which is an action rather than a setting and has no
|
||||
-- SETTINGS entry to live in.
|
||||
local ROW_HELP = {
|
||||
["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
|
||||
.. "by a 3D camera, with the numbered rungs its angle in degrees.",
|
||||
["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
|
||||
.. "look, sharp across the middle and softening above and below it.",
|
||||
["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
|
||||
.. "cartridge that 3D-BTL's STADIUM rungs need.",
|
||||
}
|
||||
|
||||
-- ------- what the menus are built from
|
||||
--
|
||||
-- SETTINGS lives in main.lua, next to the help text that goes with each row
|
||||
-- and the comments explaining every `when` and `full`. It is handed here
|
||||
-- rather than moved, so this file stays about PRESENTATION and that one stays
|
||||
-- the single place the mod's settings are declared.
|
||||
local settings = {}
|
||||
local pipelineRows = {}
|
||||
|
||||
function SettingsMenu.define(list)
|
||||
settings = list or {}
|
||||
end
|
||||
|
||||
-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
|
||||
-- the category's out of CATEGORIES, or one of the three above for the rows
|
||||
-- that have nowhere else to keep it.
|
||||
--
|
||||
-- Looked up BY ID rather than hung on the row as a field, because two of
|
||||
-- these rows are the engine's own tables reused verbatim -- and annotating
|
||||
-- somebody else's table is how a mod ends up owning a field it never meant
|
||||
-- to. nil for a row with nothing to say, which SELECT reads as "no box".
|
||||
function SettingsMenu.helpFor(id)
|
||||
if ROW_HELP[id] then return ROW_HELP[id] end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
if SettingsMenu.id(cat.id) == id then return cat.help end
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
|
||||
-- .rows), captured by the options hook on its way past and shown here instead
|
||||
-- of at the top level. Reused verbatim, tables and all: they persist in
|
||||
-- save.options.pipelines through their own step functions, and rebuilding
|
||||
-- them here would be a second implementation of a thing the engine already
|
||||
-- got right.
|
||||
function SettingsMenu.setPipelineRows(rows)
|
||||
pipelineRows = rows or {}
|
||||
end
|
||||
|
||||
-- ------- a step here has the same consequences as a step anywhere
|
||||
--
|
||||
-- Two of these settings PIN something else when they change: 3D-BTL holds
|
||||
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
|
||||
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
|
||||
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
|
||||
-- pinning on its way past.
|
||||
--
|
||||
-- Nothing reruns that hook from in here, so the pin is asked for directly.
|
||||
-- main.lua supplies it, because WHICH values follow which is a question about
|
||||
-- the mod's settings and not about the menu they are on.
|
||||
local onChanged = nil
|
||||
|
||||
function SettingsMenu.setOnChanged(fn)
|
||||
onChanged = fn
|
||||
end
|
||||
|
||||
local function isFull()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
|
||||
end
|
||||
|
||||
-- The one rule that decides whether a setting is on a menu, lifted unchanged
|
||||
-- from the options hook it used to live in.
|
||||
--
|
||||
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||
-- with it. And a row whose own switch is off the table this frame (BACK
|
||||
-- SPRITES, which needs a staged fight to be about) is left off with it. The
|
||||
-- mod manager's page carries every one of them either way.
|
||||
local function offered(entry, full)
|
||||
return (entry.full or not full) and (not entry.when or entry.when())
|
||||
end
|
||||
|
||||
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
|
||||
-- no side effects -- so a caller that only wants to know what is on a menu
|
||||
-- (a test, or the root menu asking whether a category has anything in it)
|
||||
-- does not have to push a screen to find out.
|
||||
function SettingsMenu.rows(catId, game)
|
||||
local full = isFull()
|
||||
local out = {}
|
||||
if catId == SettingsMenu.ROOT then
|
||||
for _, row in ipairs(pipelineRows) do
|
||||
-- FULL owns the blur exactly as it owns the wireframe and the horizon
|
||||
-- bend, so T-SHIFT comes off with them
|
||||
if not (full and row.id == "pipeline:tiltshift") then
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
-- ------- settings that belong to no category
|
||||
--
|
||||
-- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
|
||||
-- menu next to the pipeline rows. For a setting that is about the GAME
|
||||
-- rather than about one of the four things the categories are for --
|
||||
-- SHINY ODDS is the first -- burying it under a heading it does not
|
||||
-- belong to is worse than the flat list this menu was built to end.
|
||||
--
|
||||
-- Above the categories, because these are rows you CHANGE and those are
|
||||
-- rows you OPEN: everything with a value on it stays together at the top
|
||||
-- of the screen, and the "..." rows read as the way further in.
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
local kids = SettingsMenu.rows(cat.id, game)
|
||||
-- An EMPTY category is not offered. This is the whole of what makes
|
||||
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
|
||||
-- special case, just nothing left inside to open.
|
||||
if kids[1] then
|
||||
out[#out + 1] = {
|
||||
id = SettingsMenu.id(cat.id),
|
||||
label = cat.label,
|
||||
value = cat.summary
|
||||
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
|
||||
activate = function(g)
|
||||
g.stack:push(SettingsMenu.new(g, cat.id))
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
-- ------- and the ROM import, last, on the top-level menu
|
||||
--
|
||||
-- An ACTION and not a setting: there is no rung to store, nothing for the
|
||||
-- mod manager's page to persist and nothing to restore on the next boot,
|
||||
-- so it is appended rather than living in SETTINGS.
|
||||
--
|
||||
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
|
||||
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
|
||||
-- and wait while it builds -- and a player who has been told to import a
|
||||
-- ROM should find the row where the mod begins, not two levels down a
|
||||
-- category they have no reason to open until it has worked. Last, because
|
||||
-- the categories are what the menu is FOR.
|
||||
local ok, importRow = pcall(function()
|
||||
return V.require("StadiumRomPick").row()
|
||||
end)
|
||||
if ok and importRow then out[#out + 1] = importRow end
|
||||
return out
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == catId and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- red ink for the mod's row on the OPTIONS menu
|
||||
--
|
||||
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
|
||||
-- nobody spends an afternoon on it. Twice over:
|
||||
--
|
||||
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
|
||||
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
|
||||
-- MULTIPLICATIVELY. black x red is black.
|
||||
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
|
||||
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
|
||||
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
|
||||
--
|
||||
-- What actually happens on this screen is that setColor picks a SHADE and the
|
||||
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
|
||||
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
|
||||
-- The engine does the same thing for the party menu's HP bars
|
||||
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
|
||||
SettingsMenu.INK = { 255, 0, 0 }
|
||||
|
||||
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
|
||||
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
|
||||
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
|
||||
-- the row is the same white as the row above it in all three.
|
||||
function SettingsMenu.redPalette(data)
|
||||
local base = PaletteFX.pal(data, "MEWMON")
|
||||
if not base then return nil end
|
||||
local out = { base[1], base[2], base[3], base[4] }
|
||||
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
|
||||
-- it the ink is the first slot and the paper the last. Put the red where it
|
||||
-- will land on the INK either way: without this the row draws as a solid
|
||||
-- red block with white letters cut out of it.
|
||||
--
|
||||
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
|
||||
-- outright and substitute their own, so the row simply draws monochrome --
|
||||
-- which is correct: the player asked for a screen with no colors in it.
|
||||
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
|
||||
return out
|
||||
end
|
||||
|
||||
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
|
||||
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
|
||||
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
|
||||
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
|
||||
-- black glyphs that would turn red along with the text if the band spanned
|
||||
-- the whole row.
|
||||
function SettingsMenu.rowZone(data, slot)
|
||||
local pal = SettingsMenu.redPalette(data)
|
||||
if not pal then return nil end
|
||||
local top = (slot - 1) * 4 + 1
|
||||
return PaletteFX.zone(pal, 2, top, 18, top + 1)
|
||||
end
|
||||
|
||||
-- ------- the screen
|
||||
--
|
||||
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
|
||||
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
|
||||
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
|
||||
-- level or the battle rows change -- which would replace a submenu's rows
|
||||
-- with the whole top-level OPTIONS list under the player's cursor. A state of
|
||||
-- our own cannot be caught by it.
|
||||
--
|
||||
-- It still renders through OptionRows, so it is the same four boxes, the same
|
||||
-- cursor and the same bottom line as every other menu in the game.
|
||||
function SettingsMenu.new(game, catId)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
cat = catId or SettingsMenu.ROOT,
|
||||
index = 1,
|
||||
scroll = 0,
|
||||
}, SettingsMenu)
|
||||
self.rows = SettingsMenu.rows(self.cat, game)
|
||||
self.sig = SettingsMenu.signature(self.rows)
|
||||
return self
|
||||
end
|
||||
|
||||
function SettingsMenu.signature(rows)
|
||||
local ids = {}
|
||||
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
|
||||
return table.concat(ids, "\1")
|
||||
end
|
||||
|
||||
-- The bottom line is the only place on this screen to say anything that is
|
||||
-- not a row: OptionRows' four boxes fill everything above it and there is no
|
||||
-- header slot. It spends that line on the two buttons that are not obvious.
|
||||
--
|
||||
-- It used to carry the category's NAME instead, for orientation. The hint
|
||||
-- won: a binding nobody knows about is worth nothing, and where the player is
|
||||
-- was just answered by the row they pressed A on. Sixteen characters of the
|
||||
-- eighteen the line has, which is also why the name could not stay -- "BACK:
|
||||
-- PERFORMANCE" is seventeen on its own.
|
||||
SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
|
||||
|
||||
function SettingsMenu:backLabel()
|
||||
return SettingsMenu.BACK_LABEL
|
||||
end
|
||||
|
||||
-- A category's contents can change while the player is looking at them: 3D-BTL
|
||||
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
|
||||
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
|
||||
-- actually differs, so the common case -- every other rung of every other row
|
||||
-- -- costs one string compare.
|
||||
function SettingsMenu:refresh()
|
||||
local rows = SettingsMenu.rows(self.cat, self.game)
|
||||
local sig = SettingsMenu.signature(rows)
|
||||
if sig == self.sig then return end
|
||||
-- Follow the row the cursor was ON rather than the slot it was in: a row
|
||||
-- can appear ABOVE the one just used, which would otherwise slide the
|
||||
-- cursor onto its neighbour. The bottom line follows itself.
|
||||
local wasBack = self.index > #self.rows
|
||||
local wasOn = self.rows[self.index] and self.rows[self.index].id
|
||||
self.rows, self.sig = rows, sig
|
||||
self.index, self.scroll = 1, 0
|
||||
if wasBack then
|
||||
self.index = #rows + 1
|
||||
else
|
||||
for i, row in ipairs(rows) do
|
||||
if wasOn and row.id == wasOn then self.index = i break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function pop(self)
|
||||
local stack = self.game and self.game.stack
|
||||
if self.game and self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
if stack and stack:top() == self then stack:pop() end
|
||||
end
|
||||
|
||||
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
|
||||
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
|
||||
-- alone, and the bottom line is a synthetic index past the end of the list
|
||||
-- rather than a row, so nothing a category contains can orphan the way out.
|
||||
function SettingsMenu:update()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local rows = self.rows
|
||||
local back = #rows + 1
|
||||
local changed = false
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
if self.index < 1 then self.index = back end
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
if self.index > back then self.index = 1 end
|
||||
elseif input:wasPressed("left") or input:wasPressed("right")
|
||||
or input:wasPressed("a") then
|
||||
local dir = input:wasPressed("left") and -1 or 1
|
||||
local row = rows[self.index]
|
||||
if row and row.activate then
|
||||
if input:wasPressed("a") then row.activate(self.game) end
|
||||
elseif row and row.step then
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
elseif input:wasPressed("select") then
|
||||
-- SELECT explains the row the cursor is on. Every row on these menus has
|
||||
-- something to say -- the settings have carried a paragraph each since
|
||||
-- they were written, and nothing has ever drawn it (see SettingsHelp) --
|
||||
-- but a row that does not is simply left alone rather than opening an
|
||||
-- empty box.
|
||||
local row = rows[self.index]
|
||||
local help = row and SettingsMenu.helpFor(row.id)
|
||||
if help and self.game.stack then
|
||||
self.game.stack:push(
|
||||
V.require("SettingsHelp").new(self.game, row.label, help))
|
||||
end
|
||||
return
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- B and START both, like every other menu -- and one level only: this
|
||||
-- pops US, leaving the OPTIONS menu underneath exactly as the player
|
||||
-- left it, with its own onCancel still to fire when they leave THAT.
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
if changed then
|
||||
-- before the rebuild, not after: pinning can itself change which rows are
|
||||
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
|
||||
-- it), and refresh has to see the settled answer
|
||||
if onChanged then pcall(onChanged, self.game) end
|
||||
if self.game.writeOptions then
|
||||
pcall(self.game.writeOptions, self.game)
|
||||
end
|
||||
end
|
||||
self:refresh()
|
||||
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
|
||||
#self.rows + 1)
|
||||
end
|
||||
|
||||
function SettingsMenu:draw()
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
|
||||
self:backLabel(), #self.rows + 1)
|
||||
end
|
||||
|
||||
-- REQUIRED, even though nothing here is red.
|
||||
--
|
||||
-- Game:draw walks the stack from the top and stops at the first state that
|
||||
-- HAS this method, not the first that answers something. Without one of our
|
||||
-- own the walk would fall through to the OPTIONS menu underneath -- whose
|
||||
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
|
||||
-- zone is addressed by SLOT, so it would land on whatever this menu happens
|
||||
-- to be showing in the same box.
|
||||
--
|
||||
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
|
||||
function SettingsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
return SettingsMenu
|
||||
+23
-3
@@ -217,10 +217,27 @@ local function getBlank()
|
||||
return blank or nil
|
||||
end
|
||||
|
||||
-- Whether the player asked for shadows at all (the SHADOWS row, see
|
||||
-- lib/Shadows). Asked through a pcall because this module is loaded by
|
||||
-- probes and by the suite with no mod namespace around it, where the answer
|
||||
-- is simply yes.
|
||||
--
|
||||
-- ONE gate for both halves of the module -- can the pass run, and is there
|
||||
-- a map to read -- because they must never disagree: available() alone
|
||||
-- would leave the LAST map standing (`ready` is still true), and every
|
||||
-- surface would go on wearing shadows frozen in the pose the row was
|
||||
-- switched off in.
|
||||
function ShadowMap.wanted()
|
||||
local ok, on = pcall(function() return V.require("Shadows").enabled() end)
|
||||
return (not ok) or on
|
||||
end
|
||||
|
||||
-- Whether the sun pass can run at all. False headless, without shaders, or
|
||||
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
|
||||
-- shadows, which need nothing but a quad.
|
||||
-- shadows, which need nothing but a quad -- and false with the row off,
|
||||
-- where nothing stands in (see lib/Shadows).
|
||||
function ShadowMap.available()
|
||||
if not ShadowMap.wanted() then return false end
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
|
||||
return false
|
||||
end
|
||||
@@ -241,9 +258,12 @@ function ShadowMap.texture()
|
||||
return getBlank()
|
||||
end
|
||||
|
||||
-- True while the map holds a frame the main pass can read.
|
||||
-- True while the map holds a frame the main pass can read. The row's OFF
|
||||
-- lands here as well as on available(): a map drawn a frame ago is still in
|
||||
-- the canvas, and every reader (the scene shader's sunDark, the water's,
|
||||
-- the forest's beams) hangs off this one answer.
|
||||
function ShadowMap.active()
|
||||
return ready and canvas ~= nil and canvas ~= false
|
||||
return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted()
|
||||
end
|
||||
|
||||
-- The direction the light TRAVELS, normalized. The shear is the shadow a
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Voxel world mode: whether the sun casts at all.
|
||||
--
|
||||
-- lib/ShadowMap renders the whole scene a second time from the light every
|
||||
-- frame the view or a pose changes, at up to 2048 squared, and every
|
||||
-- surface in the main pass then takes four taps at it. That is the single
|
||||
-- most expensive thing this mode does after the geometry itself -- and on a
|
||||
-- phone, or an old laptop, it is the difference between the diorama running
|
||||
-- and the diorama stuttering. So it gets a row.
|
||||
--
|
||||
-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
|
||||
-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
|
||||
-- a stand-in for a machine that wanted shadows and could not have them.
|
||||
-- A player who has just switched them off wants no shadow under anybody,
|
||||
-- which is what this row gives -- see ShadowMap.wanted, the one gate both
|
||||
-- halves hang off.
|
||||
--
|
||||
-- This file owns the toggle rather than the drawing: the value, where it
|
||||
-- persists, and the row the player finds it on -- exactly as VoxelGrid does
|
||||
-- for the wireframe.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local Shadows = {}
|
||||
|
||||
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||
-- OPTIONS and the mod manager's own settings page for this mod
|
||||
Shadows.KEY = "shadows"
|
||||
Shadows.LABEL = "SHADOWS"
|
||||
|
||||
-- ON is values[1] and so the default: cast shadows are what the mode is
|
||||
-- for as much as the geometry is -- a world where a building throws
|
||||
-- nothing reads as flat however many voxels it is made of. The row is for
|
||||
-- the machine that cannot carry them, not a look anybody is choosing.
|
||||
Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
|
||||
{ true, false }, { "ON", "OFF" })
|
||||
|
||||
function Shadows.enabled()
|
||||
return Shadows.setting:get() and true or false
|
||||
end
|
||||
|
||||
function Shadows.set(enabled, game)
|
||||
return Shadows.setting:setIndex(enabled and 1 or 2, game)
|
||||
end
|
||||
|
||||
function Shadows.toggle(game)
|
||||
return Shadows.setting:cycle(game)
|
||||
end
|
||||
|
||||
function Shadows.sync(value)
|
||||
Shadows.setting:sync(value and true or false)
|
||||
end
|
||||
|
||||
function Shadows.row()
|
||||
return Shadows.setting:row()
|
||||
end
|
||||
|
||||
return Shadows
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
-- Shiny Pokemon: the one fact, and everywhere that asks it.
|
||||
--
|
||||
-- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no
|
||||
-- shininess of its own, but it does have the four DVs Gen 2 later read to
|
||||
-- decide it, and the engine already ships that reading:
|
||||
-- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all
|
||||
-- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment
|
||||
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
||||
-- This is that mod.
|
||||
--
|
||||
-- Deriving rather than storing is the whole design, and it buys a great
|
||||
-- deal:
|
||||
--
|
||||
-- * It persists for free. DVs are already in every save, every PC box,
|
||||
-- every trade. No new save field, no migration, and a save made before
|
||||
-- this mod was installed already HAS shiny Pokemon in it -- they were
|
||||
-- always there, nothing was ever drawn differently.
|
||||
-- * It survives evolution. Evolution.apply recalculates stats from the
|
||||
-- same dvs table and never touches it (src/pokemon/Evolution.lua:99),
|
||||
-- so a shiny Bulbasaur is a shiny Venusaur without being told.
|
||||
-- * It cannot desync. A flag stored beside the DVs is a second copy of
|
||||
-- the truth, and two copies drift -- most cruelly across a trade or a
|
||||
-- box deposit, where the mon travels and the sidecar does not.
|
||||
-- * PKHeX and the Gen 2 games agree with us, because it is their rule.
|
||||
--
|
||||
-- The odds, though, are ours to set, and that is the one thing DVs alone
|
||||
-- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 =
|
||||
-- exactly 1/8192, the classic rate, and there is no dial on it. So the roll
|
||||
-- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs
|
||||
-- (forceShiny/forceCommon below). The mon does not carry a flag saying it
|
||||
-- is shiny; it is made genuinely shiny by the game's own formula, and every
|
||||
-- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the
|
||||
-- same answer without knowing we were involved.
|
||||
--
|
||||
-- mon.shiny is maintained too, but it is a CACHE and never the source: see
|
||||
-- Shiny.mark.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
-- allowlisted for mods by name -- src/mods/Loader.lua:71 lists
|
||||
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local Shiny = {}
|
||||
|
||||
-- ------- the odds
|
||||
--
|
||||
-- One in ODDS_DENOM. The default is 8192 because that is what random DVs
|
||||
-- already produce, so a player who never changes it gets the canonical rate
|
||||
-- and the canonical feel -- this mod's default is not a buff.
|
||||
--
|
||||
-- The roll is made EXACT rather than additive. A naive implementation rolls
|
||||
-- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a
|
||||
-- miss, so the true rate is N and 8192 in parallel -- indistinguishable at
|
||||
-- the default and quietly wrong at every other setting (at 1/100 you would
|
||||
-- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no
|
||||
-- matter what you set). forceCommon on a miss closes that: the rate is what
|
||||
-- the number says.
|
||||
Shiny.ODDS_DENOM = 8192
|
||||
|
||||
-- ------- the row the player cycles
|
||||
--
|
||||
-- A ladder that HALVES, so every step is exactly "twice as often as the one
|
||||
-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
|
||||
-- rate is what the number says, not an approximation of it, because the
|
||||
-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
|
||||
-- 1:2 really is every other encounter.
|
||||
--
|
||||
-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
|
||||
-- and the fallback for an unreadable or unrecognised stored value, so the
|
||||
-- canonical rate is what a player who never opens the menu gets and what a
|
||||
-- corrupted options.lua comes back to.
|
||||
--
|
||||
-- No rung RARER than 8192. The mod's promise is that its default is not a
|
||||
-- change to the game; making the game harder than it ships is a different
|
||||
-- promise and nobody asked for it.
|
||||
local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
|
||||
|
||||
local ODDS_LABELS = {}
|
||||
for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
|
||||
|
||||
Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
|
||||
|
||||
-- ------- the setting is PULLED, not pushed
|
||||
--
|
||||
-- decide() asks this every roll rather than the menu telling us when it
|
||||
-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
|
||||
-- settings page -- and only the first has a change hook to hang on; the
|
||||
-- manager writes through mod.options and calls ModSetting:sync, which
|
||||
-- notifies nothing. Pulling is the only way both are seen, and the cost is
|
||||
-- a table read on an event that happens once per encounter.
|
||||
--
|
||||
-- ODDS_DENOM stays the live value and is written through on every ask, so
|
||||
-- anything already reading that field keeps reading the truth.
|
||||
local pinned = false
|
||||
|
||||
function Shiny.odds()
|
||||
if not pinned then
|
||||
local ok, value = pcall(Shiny.setting.get, Shiny.setting)
|
||||
local n = ok and tonumber(value)
|
||||
if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
|
||||
end
|
||||
return Shiny.ODDS_DENOM
|
||||
end
|
||||
|
||||
-- Set the denominator BY HAND, which also PINS it: a driver or a test that
|
||||
-- has asked for 1:1 means it, and must not have the next roll quietly put
|
||||
-- back to whatever the player left on the menu. Nothing in the game calls
|
||||
-- this -- the row is how a player changes the rate.
|
||||
--
|
||||
-- Guards the degenerate values because a 0 or a negative here would
|
||||
-- divide-by-zero or make every encounter shiny by accident rather than by
|
||||
-- choice; 1 (always shiny) stays reachable because it is genuinely useful
|
||||
-- for walking the whole model set.
|
||||
function Shiny.setOdds(denom)
|
||||
denom = tonumber(denom)
|
||||
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
|
||||
Shiny.ODDS_DENOM = math.floor(denom)
|
||||
pinned = true
|
||||
return Shiny.ODDS_DENOM
|
||||
end
|
||||
|
||||
-- Hand the row back control, for a test that pinned the odds and wants the
|
||||
-- setting to mean something again afterwards.
|
||||
function Shiny.unpinOdds()
|
||||
pinned = false
|
||||
return Shiny.odds()
|
||||
end
|
||||
|
||||
-- ------- reading it
|
||||
|
||||
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
|
||||
-- list as well as the engine's set because forceShiny has to CHOOSE one and
|
||||
-- wants the nearest, not just any.
|
||||
local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 }
|
||||
|
||||
-- The HP DV is not free: Gen 1 derives it from the low bit of each of the
|
||||
-- other four (src/pokemon/Stats.lua:19). Any write to the four must
|
||||
-- recompute it, or the mon ends up with an HP stat the real game could
|
||||
-- never produce -- which is exactly what a save inspector flags as illegal.
|
||||
local function syncHpDv(dvs)
|
||||
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
|
||||
(dvs.speed % 2) * 2 + (dvs.special % 2)
|
||||
return dvs
|
||||
end
|
||||
|
||||
-- The single question. Everything visual in this mod routes here.
|
||||
function Shiny.isShiny(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
return Stats.isShiny(mon.dvs) == true
|
||||
end
|
||||
|
||||
-- ------- writing it
|
||||
|
||||
-- Make these DVs satisfy the pattern, moving them as little as it allows.
|
||||
--
|
||||
-- Defense, Speed and Special have exactly one legal value each, so they are
|
||||
-- simply pinned. Attack has eight, and the nearest one to whatever was
|
||||
-- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0
|
||||
-- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter
|
||||
-- should not also be a stat reroll any larger than the pattern demands.
|
||||
local function forceShiny(dvs)
|
||||
local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil
|
||||
for _, v in ipairs(SHINY_ATK) do
|
||||
local d = math.abs(v - want)
|
||||
if not bestd or d < bestd then bestd, best = d, v end
|
||||
end
|
||||
dvs.attack = best
|
||||
dvs.defense, dvs.speed, dvs.special = 10, 10, 10
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- Make these DVs NOT satisfy the pattern, moving them as little as
|
||||
-- possible: one step on Special is enough to break it, and Special is the
|
||||
-- choice because in Gen 1 it is a single stat rather than the two Gen 2
|
||||
-- split it into, so the disturbance stays inside one number.
|
||||
--
|
||||
-- Only ever reached by a mon that rolled non-shiny and happened to be shiny
|
||||
-- by luck, which is 1/8192 of the time -- so this touches almost nothing,
|
||||
-- and what it does touch it moves by one point.
|
||||
local function forceCommon(dvs)
|
||||
if (dvs.special or 0) == 10 then
|
||||
dvs.special = 9
|
||||
elseif (dvs.defense or 0) == 10 then
|
||||
dvs.defense = 9
|
||||
end
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- mon.shiny: the cache.
|
||||
--
|
||||
-- The requirement is a flag ON the Pokemon, and this is it -- but it is
|
||||
-- written from the DVs every time we touch a mon, never read as the truth.
|
||||
-- Keeping it one-directional is what stops it becoming the second copy the
|
||||
-- header warns about: if it ever disagrees with the DVs, the DVs win and
|
||||
-- this is overwritten. It exists so other code -- and a save inspector, and
|
||||
-- a companion mod -- can ask the cheap question without importing Stats.
|
||||
function Shiny.mark(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
local is = Shiny.isShiny(mon)
|
||||
mon.shiny = is or nil -- nil rather than false: absent keeps saves clean
|
||||
return is
|
||||
end
|
||||
|
||||
-- Recalculate the stats a DV write invalidated.
|
||||
--
|
||||
-- Split out because both decide() and set() move DVs, and a mon left
|
||||
-- carrying stats computed from its old DVs is wrong in the only way the
|
||||
-- player can actually see: its HP bar.
|
||||
local function restat(mon)
|
||||
if not (mon.level and mon.species) then return end
|
||||
local ok, data = pcall(require, "src.core.Data")
|
||||
local def = ok and data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return end
|
||||
local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0)
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
-- A wild mon appears at full health, and a mon that WAS full stays full:
|
||||
-- recomputing max HP without following it here would put a freshly
|
||||
-- encountered mon on the field at less than full from its first frame.
|
||||
-- A wounded mon keeps its damage, clamped to the new maximum.
|
||||
if mon.hp then
|
||||
mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- our own randomness
|
||||
--
|
||||
-- A PRIVATE stream, not love.math.random, and that is deliberate.
|
||||
--
|
||||
-- The game's RNG is a shared sequence: damage rolls, crits, encounter
|
||||
-- slots and DV generation all draw from it in a fixed order. Taking a draw
|
||||
-- out of it for a shiny check would shift every later draw, so installing
|
||||
-- this mod would quietly change the outcome of fights that have nothing to
|
||||
-- do with shininess -- and the manifest promises `affects_link = false`,
|
||||
-- which a shifted stream would make untrue the moment two machines
|
||||
-- disagreed about whose turn consumed what.
|
||||
--
|
||||
-- Seeded off the clock rather than the save, because shininess is a fact
|
||||
-- about the encounter and not about the file: re-loading a save to re-roll
|
||||
-- a Pokemon is the hunt, and a stream keyed to the save would hand back the
|
||||
-- same answer every time.
|
||||
local stream = nil
|
||||
|
||||
local function roll(n)
|
||||
if not stream then
|
||||
if love and love.math and love.math.newRandomGenerator then
|
||||
stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6)
|
||||
else
|
||||
-- headless (tests): math.random is nobody's shared sequence there
|
||||
stream = { random = function(_, a, b) return math.random(a, b) end }
|
||||
end
|
||||
end
|
||||
return stream:random(1, n)
|
||||
end
|
||||
|
||||
-- Decide a freshly-built mon, in place.
|
||||
--
|
||||
-- rng may be passed to pin the verdict -- a test hands us a stub. Left nil,
|
||||
-- the private stream above is used.
|
||||
function Shiny.decide(mon, rng)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
|
||||
-- stub and the call below reads identically either way
|
||||
rng = rng or function(_lo, hi) return roll(hi) end
|
||||
local hit = rng(1, Shiny.odds()) == 1
|
||||
|
||||
if hit then
|
||||
forceShiny(mon.dvs)
|
||||
restat(mon)
|
||||
elseif Stats.isShiny(mon.dvs) then
|
||||
forceCommon(mon.dvs)
|
||||
restat(mon)
|
||||
end
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
-- Force a specific verdict: for tests, and for a scripted gift mon that
|
||||
-- wants to be shiny on purpose.
|
||||
function Shiny.set(mon, on)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end
|
||||
restat(mon)
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
return Shiny
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Where shininess enters the game, and where it is shown.
|
||||
--
|
||||
-- ------- one seam decides it
|
||||
--
|
||||
-- Every Pokemon the player can ever own is built by Pokemon.new
|
||||
-- (src/pokemon/Pokemon.lua:60). There are five callers and they are the
|
||||
-- whole surface:
|
||||
--
|
||||
-- BattleState.lua:569 the wild encounter
|
||||
-- BattleState.lua:659 a trainer's party
|
||||
-- BattleState.lua:785 the level-5 stand-in the Oak battle builds
|
||||
-- Commands.lua:656 a gift or a starter
|
||||
-- Commands.lua:969 an in-game trade
|
||||
--
|
||||
-- So the roll goes THERE rather than on the encounter hooks. Two reasons,
|
||||
-- and the second is decisive:
|
||||
--
|
||||
-- * encounter.roll and encounter.species fire before the mon exists --
|
||||
-- they carry {species, level} and nothing to write a verdict onto.
|
||||
-- * makeBattler bakes mon.sprite INSIDE newWild
|
||||
-- (src/battle/BattleState.lua:455-461), before battle.started is
|
||||
-- emitted. A verdict applied at battle.started is already too late for
|
||||
-- the sprite the fight will draw.
|
||||
--
|
||||
-- Wrapping the constructor puts the decision before every one of those, and
|
||||
-- picks up gift mons, starters and trades for free rather than needing a
|
||||
-- seam each.
|
||||
--
|
||||
-- TRAINER MONS COME OUT NON-SHINY BY THEMSELVES, and correctly so. The
|
||||
-- engine overwrites every trainer slot's DVs with a fixed TRAINER_DVS
|
||||
-- (src/battle/BattleState.lua:350, :661) right after construction, and that
|
||||
-- constant fails the shiny pattern. So the roll is made and then discarded
|
||||
-- for them -- which matches the real games, where a trainer's Pokemon is
|
||||
-- never shiny.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyBattle = {}
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses
|
||||
-- (OverworldBattle.install, Stadium.install): a hot reload must not stack a
|
||||
-- second copy of the wrapper on top of the first.
|
||||
function ShinyBattle.install()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if not Pokemon.dramaticShapeShiny then
|
||||
local inner = Pokemon.new
|
||||
function Pokemon.new(data, species, level, rng)
|
||||
local mon = inner(data, species, level, rng)
|
||||
-- pcall: a mon that fails to be decided is an ordinary mon, which is
|
||||
-- a blemish. A mon that fails to be BUILT is a broken game.
|
||||
pcall(Shiny.decide, mon)
|
||||
return mon
|
||||
end
|
||||
Pokemon.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- Party mons that predate the mod, and any mon built by a path we have
|
||||
-- not wrapped, still answer isShiny correctly -- their DVs were always
|
||||
-- there. This only refreshes the mon.shiny cache so a save opened for the
|
||||
-- first time under this mod has the field populated rather than absent
|
||||
-- until the mon next changes.
|
||||
ShinyBattle.markParty()
|
||||
end
|
||||
|
||||
-- Refresh the cached flag across the player's party.
|
||||
function ShinyBattle.markParty()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not ok then return end
|
||||
local party = Game and Game.save and Game.save.party
|
||||
if type(party) ~= "table" then return end
|
||||
for _, mon in ipairs(party) do
|
||||
pcall(Shiny.mark, mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- asking about a battler
|
||||
--
|
||||
-- The battler wrapper carries the save-shaped mon on `.mon`
|
||||
-- (src/battle/BattleState.lua:432-463), so the question is always about
|
||||
-- that table and never about the wrapper.
|
||||
function ShinyBattle.battlerIsShiny(battler)
|
||||
return battler ~= nil and Shiny.isShiny(battler.mon)
|
||||
end
|
||||
|
||||
-- Which side of a battle, by the engine's own side names.
|
||||
function ShinyBattle.sideIsShiny(battle, side)
|
||||
if not battle then return false end
|
||||
return ShinyBattle.battlerIsShiny(side == "player" and battle.player
|
||||
or battle.enemy)
|
||||
end
|
||||
|
||||
return ShinyBattle
|
||||
@@ -0,0 +1,323 @@
|
||||
-- The arrival sparkle, on the FLAT battle screen.
|
||||
--
|
||||
-- ------- why ShinyFx could not be reused
|
||||
--
|
||||
-- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it
|
||||
-- is about the 3D arena: it is armed from Stadium.update on the frame a
|
||||
-- side's model changes, it is sized from the model's own world height and
|
||||
-- radius, and it draws additive quads into the voxel scene through
|
||||
-- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no
|
||||
-- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards
|
||||
-- rather than building a model to measure.
|
||||
--
|
||||
-- So the effect was Stadium-only, and had been since it was written: ShinyFx
|
||||
-- .arm is called from exactly one file. On every other rung a shiny simply
|
||||
-- appeared, with no announcement. This is the announcement, in the one
|
||||
-- coordinate space those rungs share -- the Game Boy's own 160x144 grid,
|
||||
-- where the pic itself is drawn.
|
||||
--
|
||||
-- ------- the two slots
|
||||
--
|
||||
-- Both are the engine's, and neither moves: the enemy's front pic lives in
|
||||
-- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's
|
||||
-- back pic stands at x=8 with its feet on the text box at y=96, two-times
|
||||
-- scaled, so it fills y 32..96. The burst springs from a point inside each,
|
||||
-- a little above centre, which is roughly where a Pokemon's chest is in art
|
||||
-- drawn to fill its box.
|
||||
--
|
||||
-- Deliberately NOT measured off the drawn image. resolveBattleScale can
|
||||
-- rescale a pic per species, the send-out grow animates the scale from zero,
|
||||
-- and following either would make the burst jump around during exactly the
|
||||
-- moment it is playing. The slot is fixed; the sparkle uses the slot.
|
||||
--
|
||||
-- ------- black AND white, both
|
||||
--
|
||||
-- Each spark is drawn twice: a wider near-black cross, then a white one
|
||||
-- inside it. One colour alone would be invisible half the time -- the battle
|
||||
-- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D
|
||||
-- rungs composite the same pic over a sky or a map, where a black one does.
|
||||
-- The pair reads on both, and costs ten extra rectangles.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyFlash = {}
|
||||
|
||||
ShinyFlash.LIFE = 0.75 -- seconds, matching ShinyFx
|
||||
ShinyFlash.SPARKS = 9
|
||||
|
||||
-- The two slots, in GB pixels: where the burst starts and how far it travels.
|
||||
ShinyFlash.SLOTS = {
|
||||
enemy = { x = 124, y = 24, rx = 34, ry = 26 },
|
||||
player = { x = 40, y = 62, rx = 34, ry = 30 },
|
||||
}
|
||||
|
||||
-- Where each spark sits on the ring, as a fraction of a turn. Spread by hand
|
||||
-- rather than randomly: nine sparks on an even ring reads as a ring, and nine
|
||||
-- random ones read as a mess at this size. The half-step offset on alternate
|
||||
-- sparks keeps it from looking like a clock face.
|
||||
local ANGLES = {}
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0)
|
||||
/ ShinyFlash.SPARKS
|
||||
end
|
||||
|
||||
-- ------- why the clock is the WALL clock
|
||||
--
|
||||
-- Everything here happens on the DRAW side (see install), and a draw is
|
||||
-- handed no dt. Rather than accumulate one nobody offers, a burst records
|
||||
-- the time it started and its age is read back off love.timer.
|
||||
--
|
||||
-- That also makes it immune to being asked to draw more than once in a
|
||||
-- frame, which the wide layout does -- once per side -- and which a
|
||||
-- per-call dt accumulator would age at double speed.
|
||||
local function now()
|
||||
return (love.timer and love.timer.getTime and love.timer.getTime()) or 0
|
||||
end
|
||||
|
||||
-- live bursts: side -> the time it started
|
||||
local live = {}
|
||||
|
||||
-- what each side's pic was showing last frame, so an arrival is an EDGE
|
||||
local showing = {}
|
||||
|
||||
-- for the tests and the shot drivers, the way ShinyFx.debug is
|
||||
ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0,
|
||||
armed = 0, draws = 0, sparks = 0, err = "" }
|
||||
|
||||
function ShinyFlash.arm(side)
|
||||
live[side] = now()
|
||||
ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1
|
||||
end
|
||||
|
||||
function ShinyFlash.clear(side)
|
||||
live[side] = nil
|
||||
end
|
||||
|
||||
function ShinyFlash.reset()
|
||||
live, showing = {}, {}
|
||||
end
|
||||
|
||||
-- How far through its life this side's burst is, 0..1, or nil when there
|
||||
-- isn't one (or it has finished, which retires it on the way past).
|
||||
function ShinyFlash.age(side)
|
||||
local started = live[side]
|
||||
if not started then return nil end
|
||||
local u = (now() - started) / ShinyFlash.LIFE
|
||||
if u >= 1 then
|
||||
live[side] = nil
|
||||
return nil
|
||||
end
|
||||
return u
|
||||
end
|
||||
|
||||
function ShinyFlash.active(side)
|
||||
return ShinyFlash.age(side) ~= nil
|
||||
end
|
||||
|
||||
-- ------- is a Pokemon's own pic on screen for this side
|
||||
--
|
||||
-- The conditions are the engine's, read off drawPicsLayer rather than
|
||||
-- guessed: a side showing a TRAINER is showing a person and not a Pokemon,
|
||||
-- and the send-out, the faint fade and the safari/demo cases each have their
|
||||
-- own reason for the slot to be empty.
|
||||
--
|
||||
-- Returns the mon whose pic is up, or nil.
|
||||
function ShinyFlash.occupant(battle, side)
|
||||
if type(battle) ~= "table" then return nil end
|
||||
if side == "enemy" then
|
||||
if battle.showEnemyTrainer and battle.trainerPic then return nil end
|
||||
local b = battle.enemy
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.enemyHidden or battle.enemySendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
if battle.showPlayerBack and battle.playerBackPic then return nil end
|
||||
if battle.safari or battle.demo then return nil end
|
||||
local b = battle.player
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.sendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
|
||||
-- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a
|
||||
-- switch and a wild foe's first appearance alike, which is the same edge
|
||||
-- ShinyFx picks for the models.
|
||||
function ShinyFlash.follow(battle)
|
||||
ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = ShinyFlash.occupant(battle, side)
|
||||
if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end
|
||||
if mon ~= showing[side] then
|
||||
showing[side] = mon
|
||||
if mon and Shiny.isShiny(mon) then
|
||||
ShinyFlash.arm(side)
|
||||
else
|
||||
ShinyFlash.clear(side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Whole pixels. The screen this lands on is 160x144 and everything else in
|
||||
-- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing
|
||||
-- on a hard-edged frame.
|
||||
local function spark(px, py, arm)
|
||||
local g = love.graphics
|
||||
px, py = math.floor(px + 0.5), math.floor(py + 0.5)
|
||||
-- the dark cross first, one pixel proud of the light one on every side
|
||||
g.setColor(0, 0, 0, 1)
|
||||
g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3)
|
||||
g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.rectangle("fill", px - arm, py, arm * 2 + 1, 1)
|
||||
g.rectangle("fill", px, py - arm, 1, arm * 2 + 1)
|
||||
end
|
||||
|
||||
-- One side's burst, if it has one.
|
||||
function ShinyFlash.draw(side, sx, sy)
|
||||
local u = ShinyFlash.age(side)
|
||||
if not u then return end
|
||||
local slot = ShinyFlash.SLOTS[side]
|
||||
if not slot then return end
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
|
||||
-- Out and fading. The ring eases OUT rather than travelling at a constant
|
||||
-- speed -- fast off the mark, slow at the edge -- because a burst that
|
||||
-- decelerates reads as thrown and one that does not reads as a wipe.
|
||||
local ease = 1 - (1 - u) * (1 - u)
|
||||
local fade = 1 - u
|
||||
g.setColor(1, 1, 1, 1)
|
||||
ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1
|
||||
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
-- every third spark is held back a little, so the ring has some depth
|
||||
-- rather than nine points on one circle
|
||||
local lag = (i % 3 == 0) and 0.78 or 1
|
||||
local ang = ANGLES[i] * math.pi * 2
|
||||
local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag
|
||||
local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag
|
||||
-- arms shrink as the spark fades, so it goes out rather than vanishing
|
||||
local arm = 1 + math.floor(fade * 2.5)
|
||||
spark(px, py, arm)
|
||||
ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1
|
||||
end
|
||||
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- BEHIND the Pokemon, not over it
|
||||
--
|
||||
-- The burst springs from inside the mon and flies outward, so the frames that
|
||||
-- matter most are the ones where the ring is still small and sitting ON the
|
||||
-- body. Drawn from the overlay hook -- the end of the battle draw -- every one
|
||||
-- of those lands in FRONT of the pic, and the sparkle reads as stuck to the
|
||||
-- glass rather than as coming from the Pokemon.
|
||||
--
|
||||
-- So it is drawn from the PICS LAYER instead, before the engine's own pics go
|
||||
-- down. That is the only place in the frame that is behind the mon and in
|
||||
-- front of the field.
|
||||
--
|
||||
-- The overlay hook stays, and is still the only seam the 3D rungs have:
|
||||
-- OverworldBattle captured drawPicsLayer at install time and its battle draw
|
||||
-- calls the captured copy, so the wrap below never runs there. Whichever seam
|
||||
-- fires first draws; the other one sees the side already spent and leaves it
|
||||
-- alone. `spent` is cleared by the overlay, which is the one call guaranteed
|
||||
-- to happen exactly once per battle draw.
|
||||
local spent = {}
|
||||
|
||||
-- One side, unless it has already been drawn this frame.
|
||||
local function once(side, sx, sy)
|
||||
if spent[side] then return end
|
||||
spent[side] = true
|
||||
ShinyFlash.draw(side, sx, sy)
|
||||
end
|
||||
|
||||
-- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout
|
||||
-- drawing one side per call, and is honoured so the burst lands in the same
|
||||
-- pass its Pokemon does.
|
||||
--
|
||||
-- Skipped while the layer is SLIDING (the intro walks the whole battle in
|
||||
-- from the side): the slot this draws to is fixed, so a burst during the
|
||||
-- slide would sit still while the mon travelled past it. Nothing is lost --
|
||||
-- the arrival edge that arms it is after the slide is over.
|
||||
function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide)
|
||||
ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1
|
||||
ShinyFlash.follow(battle)
|
||||
if (slide or 0) ~= 0 then return end
|
||||
if onlySide ~= "player" then once("enemy", sx, sy) end
|
||||
if onlySide ~= "enemy" then once("player", sx, sy) end
|
||||
end
|
||||
|
||||
-- Follow the occupants and draw whatever the pics layer did not, in one call.
|
||||
function ShinyFlash.render(battle)
|
||||
ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1
|
||||
ShinyFlash.follow(battle)
|
||||
once("enemy", 0, 0)
|
||||
once("player", 0, 0)
|
||||
spent = {} -- one battle draw ends here; the next is new
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Through the engine's own `battle.overlay` hook, whose comment at the call
|
||||
-- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires
|
||||
-- at the very end of BattleState:draw, in the Game Boy's own 160x144 space,
|
||||
-- with the battle as its argument -- which is all this needs.
|
||||
--
|
||||
-- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth
|
||||
-- recording so it is not tried again: BattleState:update never fires during
|
||||
-- the intro, because the battle is not the top of the stack there and
|
||||
-- StateStack:update only calls the top. Measured -- installed, confirmed live
|
||||
-- on the class, zero calls -- rather than reasoned about.
|
||||
--
|
||||
-- The hook has no shake offset to give, and does not need one: it is called
|
||||
-- after the screen-shake translate has been popped, so nominal coordinates
|
||||
-- are the right ones.
|
||||
--
|
||||
-- ------- and the second seam, for depth
|
||||
--
|
||||
-- The overlay alone draws the burst OVER the Pokemon. The pics layer is
|
||||
-- wrapped as well so it can go down BEHIND it (see renderBehind), on every
|
||||
-- rung where the engine's own method is the one called. On the 3D rungs it is
|
||||
-- not -- OverworldBattle captured drawPicsLayer at install time and calls the
|
||||
-- captured copy -- and there the overlay is still the seam, which is why both
|
||||
-- are installed rather than one replacing the other.
|
||||
function ShinyFlash.install()
|
||||
local mod = V.mod
|
||||
if not (mod and mod.hooks and mod.hooks.wrap) then return false end
|
||||
if ShinyFlash.installed then return true end
|
||||
mod.hooks:wrap("battle.overlay", function(next, battle)
|
||||
local out = next(battle)
|
||||
local ok, err = pcall(ShinyFlash.render, battle)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return out
|
||||
end)
|
||||
|
||||
local okBS, BattleState = pcall(require, "src.battle.BattleState")
|
||||
if okBS and type(BattleState) == "table"
|
||||
and type(BattleState.drawPicsLayer) == "function"
|
||||
and not BattleState.dramaticShapeShinyFlash then
|
||||
local inner = BattleState.drawPicsLayer
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...)
|
||||
ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1
|
||||
local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy,
|
||||
onlySide)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return inner(self, slide, sx, sy, onlySide, ...)
|
||||
end
|
||||
BattleState.dramaticShapeShinyFlash = true
|
||||
end
|
||||
|
||||
ShinyFlash.installed = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyFlash
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
-- The shiny sparkle: the flash a Pokemon makes when it first appears.
|
||||
--
|
||||
-- The games announce a shiny with a burst of stars over the sprite the
|
||||
-- instant it lands, before the first text box. This is that moment, in the
|
||||
-- diorama: a ring of additive stars that springs outward from the mon's
|
||||
-- chest, rises, and fades over about three quarters of a second.
|
||||
--
|
||||
-- ------- where the moment IS
|
||||
--
|
||||
-- Harder than it sounds, because the two battle paths arrive differently:
|
||||
--
|
||||
-- the model rung a Pokemon grows out of its ball -- Stadium.update
|
||||
-- already finds that frame (the POOF_ANIM edge) and
|
||||
-- calls StadiumMon:beginGrow.
|
||||
-- the pic rung a WILD foe is simply THERE on the first frame, with
|
||||
-- no poof and no grow at all. There is no animation to
|
||||
-- hang off.
|
||||
--
|
||||
-- So the arming edge is neither of those: it is the frame a side's OCCUPANT
|
||||
-- changes (Stadium's `session.at[side] ~= battler` test, the same identity
|
||||
-- the mode already uses because a trainer leading with two Rattata changes
|
||||
-- occupant without changing species). That edge fires for a send-out, a
|
||||
-- switch and a wild foe alike, which is exactly the set of moments a shiny
|
||||
-- should announce itself.
|
||||
--
|
||||
-- ------- drawn additively, and why it survives the flash
|
||||
--
|
||||
-- Stars are light, so they add rather than cover: `Voxel3D.blend("add")`,
|
||||
-- the same treatment the Poke Ball's glow gets. They are drawn inside the
|
||||
-- battle's flash window alongside the cards and models, so a sparkle during
|
||||
-- a hit flash is lit by it like everything else rather than floating over
|
||||
-- it as a separate layer.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Mat4 = V.require("Mat4")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
|
||||
local ShinyFx = {}
|
||||
|
||||
local max, min = math.max, math.min
|
||||
|
||||
-- ------- shape and timing
|
||||
|
||||
-- ------- sized to the Pokemon, not to a constant
|
||||
--
|
||||
-- A Pokemon on the map is between 5 and 18 world pixels tall
|
||||
-- (StadiumMon.MIN_HEIGHT/MAX_HEIGHT, REF_HEIGHT 14) and roughly its own
|
||||
-- radius wide. Every earlier attempt here used flat numbers and every one of
|
||||
-- them was wrong for most of the dex: first a ring 7 units across, which sat
|
||||
-- INSIDE anything bigger than a Rattata and was depth-rejected; then, over-
|
||||
-- correcting, a ring 24 across starting 20 units up -- taller than the
|
||||
-- tallest Pokemon there is, so it hung in the sky above a Ponyta with
|
||||
-- nothing under it.
|
||||
--
|
||||
-- One ring cannot fit a Diglett and a Gyarados. The burst is therefore a
|
||||
-- FRACTION of the mon it belongs to: Stadium hands us each side's
|
||||
-- worldHeight and worldRadius (StadiumMon has them, and worldRadius exists
|
||||
-- precisely so "a caller can size something to its footprint"), and every
|
||||
-- distance below is measured off those.
|
||||
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
|
||||
ShinyFx.STARS = 10 -- around the ring
|
||||
|
||||
ShinyFx.CHEST_FRAC = 0.50 -- up the body: the ring is centred on the
|
||||
-- Pokemon, not perched above or below it
|
||||
ShinyFx.RISE_FRAC = 0.16 -- of its height, drifted up over the burst
|
||||
ShinyFx.SIZE_FRAC = 0.20 -- a star, as a fraction of the mon's height
|
||||
|
||||
-- THE RING IS AN ELLIPSE AROUND THE SILHOUETTE, with its two axes measured
|
||||
-- separately. A single radius cannot do this: flattened enough to look like
|
||||
-- a ring seen from the battle's low seat, its vertical reach ends up a third
|
||||
-- of the body's height, so the top and bottom stars sit ON the Pokemon. The
|
||||
-- horizontal axis clears its width, the vertical axis clears its height.
|
||||
ShinyFx.RING_X_FRAC = 1.50 -- of the mon's RADIUS -- just outside its width
|
||||
ShinyFx.RING_X_MIN = 0.34 -- ...but never narrower than this of its height,
|
||||
-- for the thin ones (Onix, Ekans) whose radius
|
||||
-- alone would put the ring inside them
|
||||
ShinyFx.RING_Y_FRAC = 0.62 -- of its HEIGHT -- so the ring reaches its
|
||||
-- shoulders and its feet, not just its middle
|
||||
|
||||
-- The burst OPENS from here rather than from nothing. Springing out of a
|
||||
-- point means every star spends the first frames stacked at the centre --
|
||||
-- which is the middle of the Pokemon, and reads exactly like the sparkles
|
||||
-- being stuck inside it. Starting already clear of the body and expanding
|
||||
-- the rest of the way keeps them outside for the whole life of the effect.
|
||||
ShinyFx.RING_START = 0.72
|
||||
|
||||
-- What a side with no model gets: the flat-pic rung, where a pic stands
|
||||
-- FULL_W (16) units wide in a card. Close enough to a median Pokemon that
|
||||
-- the same fractions land sensibly.
|
||||
ShinyFx.DEFAULT_HEIGHT = 14
|
||||
ShinyFx.DEFAULT_RADIUS = 6
|
||||
|
||||
-- Additive drawing keeps the depth TEST (Voxel3D.blend sets lequal with
|
||||
-- writes off), so a star level with the model is rejected by it however
|
||||
-- bright it is. The extra pull puts the ring in front of the Pokemon it
|
||||
-- belongs to, the same trick the move-animation card uses.
|
||||
ShinyFx.PULL_BONUS = 6
|
||||
|
||||
-- one per side, nil when nothing is playing
|
||||
local live = { player = nil, enemy = nil }
|
||||
|
||||
-- How big the Pokemon on each side actually is, pushed in by Stadium.update
|
||||
-- every frame it has a model. Kept here rather than reached for, because
|
||||
-- ShinyFx is drawn from BattleScene and asking Stadium from inside it would
|
||||
-- close a require loop between the three.
|
||||
local size = { player = nil, enemy = nil }
|
||||
|
||||
-- world pixels, from StadiumMon:worldHeight/worldRadius. Pass nil height to
|
||||
-- say "no model on this side" -- the flat-pic rung, which falls back to the
|
||||
-- defaults above.
|
||||
function ShinyFx.setMetrics(side, height, radius)
|
||||
if side ~= "player" and side ~= "enemy" then return end
|
||||
if not (height and height > 0) then size[side] = nil return end
|
||||
size[side] = { h = height, r = radius or 0 }
|
||||
end
|
||||
local star = nil -- the generated star image, built once
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Generated rather than shipped: it is a four-pointed twinkle, which is a
|
||||
-- cheap closed form (a radial falloff times a cross-shaped spike term) and
|
||||
-- costs nothing next to an asset that would have to be authored, packed,
|
||||
-- loaded and kept in step with the rest of the mod's art.
|
||||
local function starImage()
|
||||
if star ~= nil then return star or nil end
|
||||
if not (love and love.image and love.graphics) then
|
||||
star = false
|
||||
return nil
|
||||
end
|
||||
local ok, img = pcall(function()
|
||||
local N = 32
|
||||
local data = love.image.newImageData(N, N)
|
||||
local c = (N - 1) / 2
|
||||
for y = 0, N - 1 do
|
||||
for x = 0, N - 1 do
|
||||
local dx, dy = (x - c) / c, (y - c) / c
|
||||
local r = math.sqrt(dx * dx + dy * dy)
|
||||
-- the body: a soft core that is gone by the edge of the square
|
||||
local core = math.max(0, 1 - r)
|
||||
core = core * core * core
|
||||
-- the spikes: bright along the two axes, narrow, and reaching
|
||||
-- further out than the core does
|
||||
local ax, ay = math.abs(dx), math.abs(dy)
|
||||
local spike = math.max(0, 1 - ax * 6) * math.max(0, 1 - ay)
|
||||
+ math.max(0, 1 - ay * 6) * math.max(0, 1 - ax)
|
||||
local a = math.min(1, core + spike * 0.55)
|
||||
-- white with the faintest warm cast, so a sparkle over a cool
|
||||
-- model still reads as light rather than as a blue smear
|
||||
data:setPixel(x, y, 1, 1, 0.97, a)
|
||||
end
|
||||
end
|
||||
return love.graphics.newImage(data)
|
||||
end)
|
||||
star = (ok and img) or false
|
||||
return star or nil
|
||||
end
|
||||
|
||||
-- ------- arming
|
||||
|
||||
-- Start (or restart) the burst on one side. Restarting rather than ignoring
|
||||
-- a second call is deliberate: a shiny that faints and is sent back out
|
||||
-- should sparkle again.
|
||||
-- ARMED, BUT NOT YET RUNNING. The clock does not start here, and that is the
|
||||
-- whole point: the edge this is armed on -- a side's occupant changing --
|
||||
-- happens while the screen is still mid-WIPE, a second or more before the
|
||||
-- battle draws a single frame. A burst that started its three-quarter-second
|
||||
-- life at that moment was always over before anybody could see it, which is
|
||||
-- exactly what "the sparkle isn't appearing" looked like: armed, drawn,
|
||||
-- counted, and finished behind the transition.
|
||||
--
|
||||
-- So `pending` holds it at frame zero until the scene actually draws this
|
||||
-- side (see draw), and the life begins from there.
|
||||
function ShinyFx.arm(side)
|
||||
if side ~= "player" and side ~= "enemy" then return end
|
||||
live[side] = { t = 0, pending = true }
|
||||
if ShinyFx.debug then ShinyFx.debug.armed = (ShinyFx.debug.armed or 0) + 1 end
|
||||
end
|
||||
|
||||
-- The fight is on screen now: let any burst waiting on this side begin.
|
||||
--
|
||||
-- Split from arm because the two moments are genuinely different and were
|
||||
-- conflated twice. Arming happens when the OCCUPANT changes, which is during
|
||||
-- the transition; the burst may only start once the transition is OVER and
|
||||
-- there is somebody watching. Between them it sits at zero.
|
||||
function ShinyFx.release(side)
|
||||
local s = live[side]
|
||||
if s and s.pending then
|
||||
s.pending = nil
|
||||
if ShinyFx.debug then
|
||||
ShinyFx.debug.released = (ShinyFx.debug.released or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function ShinyFx.clear(side)
|
||||
if ShinyFx.debug and side and live[side] then
|
||||
ShinyFx.debug.cleared = (ShinyFx.debug.cleared or 0) + 1
|
||||
end
|
||||
if side then live[side] = nil else live.player, live.enemy = nil, nil end
|
||||
end
|
||||
|
||||
function ShinyFx.active(side)
|
||||
if side then return live[side] ~= nil end
|
||||
return live.player ~= nil or live.enemy ~= nil
|
||||
end
|
||||
|
||||
function ShinyFx.update(dt)
|
||||
dt = dt or 0
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
-- a pending burst does not age: it is waiting for the scene to draw it
|
||||
-- for the first time, which is when its life actually begins (see arm)
|
||||
if s and not s.pending then
|
||||
s.t = s.t + dt
|
||||
if s.t >= ShinyFx.LIFE then live[side] = nil end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
|
||||
-- Eased so the ring leaves fast and settles, which is what a spark does;
|
||||
-- linear looks like a diagram of a spark.
|
||||
local function easeOut(u) return 1 - (1 - u) * (1 - u) end
|
||||
|
||||
-- Draw whatever is playing. `arena` and `groundY` come from the scene, the
|
||||
-- same two the mon cards are placed from, so a sparkle lands where its
|
||||
-- Pokemon is standing rather than where the layout thinks it should be.
|
||||
-- Why a burst did not draw, for a driver to read back. Rendering faults are
|
||||
-- invisible to the test suite and this one has four separate ways to be a
|
||||
-- no-op, all of them silent.
|
||||
ShinyFx.debug = { calls = 0, noArena = 0, noImage = 0, noMesh = 0,
|
||||
noLive = 0, quads = 0, armed = 0, cleared = 0 }
|
||||
|
||||
function ShinyFx.draw(arena, groundY, pull)
|
||||
local dbg = ShinyFx.debug
|
||||
dbg.calls = dbg.calls + 1
|
||||
if not arena then dbg.noArena = dbg.noArena + 1 return end
|
||||
local img = starImage()
|
||||
if not img then dbg.noImage = dbg.noImage + 1 return end
|
||||
local mesh = BattleBillboard.mesh()
|
||||
if not mesh then dbg.noMesh = dbg.noMesh + 1 return end
|
||||
if not (live.player or live.enemy) then
|
||||
dbg.noLive = dbg.noLive + 1
|
||||
return
|
||||
end
|
||||
|
||||
local drew = false
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
-- A pending burst is not drawn at all. It is waiting for the fight to be
|
||||
-- ON SCREEN, which is not the same as the scene being drawn: the battle
|
||||
-- renders underneath the transition wipe for a second or so first, and a
|
||||
-- burst started there spends its whole life behind it. Stadium.release
|
||||
-- is what says the wipe is done.
|
||||
if s and s.pending then s = nil end
|
||||
if s and cell then
|
||||
local u = math.min(1, s.t / ShinyFx.LIFE)
|
||||
local e = easeOut(u)
|
||||
-- bright immediately, then out: the announcement is the first frame
|
||||
local alpha = 1 - u * u
|
||||
local x, z = cell[1], cell[2]
|
||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
||||
|
||||
-- every distance measured off THIS Pokemon (see the header)
|
||||
local m = size[side]
|
||||
local mh = (m and m.h) or ShinyFx.DEFAULT_HEIGHT
|
||||
local mr = (m and m.r and m.r > 0 and m.r) or ShinyFx.DEFAULT_RADIUS
|
||||
local ringX = max(mr * ShinyFx.RING_X_FRAC, mh * ShinyFx.RING_X_MIN)
|
||||
local ringY = mh * ShinyFx.RING_Y_FRAC
|
||||
local starK = mh * ShinyFx.SIZE_FRAC
|
||||
-- open from clear of the body, not from a point (see RING_START)
|
||||
local grow = ShinyFx.RING_START + (1 - ShinyFx.RING_START) * e
|
||||
local baseY = groundY + mh * ShinyFx.CHEST_FRAC
|
||||
+ mh * ShinyFx.RISE_FRAC * e
|
||||
|
||||
if not drew then
|
||||
Voxel3D.blend("add")
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
drew = true
|
||||
end
|
||||
|
||||
for i = 1, ShinyFx.STARS do
|
||||
-- the ring is offset half a step per side so the two sides do not
|
||||
-- twinkle in lockstep when both are shiny
|
||||
local a = (i / ShinyFx.STARS) * math.pi * 2
|
||||
+ (side == "player" and 0.31 or 0)
|
||||
-- stars shrink as they fade, and alternate size so the ring reads
|
||||
-- as scattered rather than as a cog
|
||||
local k = starK * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
|
||||
local ox = math.cos(a) * ringX * grow
|
||||
local oy = math.sin(a) * ringY * grow
|
||||
local m = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(x, baseY, z), Mat4.rotateY(yaw)),
|
||||
Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(k, k, 1)))
|
||||
love.graphics.setColor(1, 1, 1, alpha)
|
||||
Voxel3D.draw(mesh, img, m, (pull or 0) + ShinyFx.PULL_BONUS)
|
||||
dbg.quads = dbg.quads + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if drew then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.blend("alpha")
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop the generated image (hot reload, or a graphics context that went
|
||||
-- away) -- the same contract StadiumPack.invalidate honours.
|
||||
function ShinyFx.invalidate()
|
||||
if star and star.release then pcall(star.release, star) end
|
||||
star = nil
|
||||
ShinyFx.clear()
|
||||
end
|
||||
|
||||
return ShinyFx
|
||||
@@ -0,0 +1,527 @@
|
||||
-- The shiny recolour: Stadium's own HSL slide, run over decoded texels.
|
||||
--
|
||||
-- THE COLOUR MODEL IS STADIUM'S, not an invention. The Stadium games do not
|
||||
-- ship a second set of textures for a shiny Pokemon; they convert the
|
||||
-- colours the model already has to HSL and slide them -- a hue rotation in
|
||||
-- degrees, plus saturation and lightness on a quantized integer scale of
|
||||
-- -8..+8 where 0 is no change. One step is 12.5%, so +-8 is +-100%: exactly
|
||||
-- the range of GIMP's Hue-Saturation sliders, which is where the 12.5%
|
||||
-- figure was measured. s = -8 is full greyscale, l = +8 is white.
|
||||
--
|
||||
-- That equivalence is why the maths below is GIMP's Hue-Saturation and not
|
||||
-- a plain additive offset:
|
||||
--
|
||||
-- saturation s' = s * (1 + k) multiplicative
|
||||
-- lightness l' = l * (1 + k) k < 0 scale toward black
|
||||
-- l' = l + k * (1 - l) k > 0 blend toward white
|
||||
--
|
||||
-- The multiplicative saturation is the reason this is safe to run over a
|
||||
-- whole texture rather than a masked region: a pixel with no saturation --
|
||||
-- an eye white, a tooth, a grey shadow -- is immune to BOTH the hue
|
||||
-- rotation and the saturation step, for free and by construction. Only the
|
||||
-- lightness step touches achromatic pixels, which is why the species
|
||||
-- carrying big l values (Golbat and Slowpoke at -6, Moltres at +5) are the
|
||||
-- ones worth looking at with human eyes.
|
||||
--
|
||||
-- FIVE SPECIES CANNOT BE SLID. Clefairy, Clefable, Jigglypuff, Wigglytuff
|
||||
-- and Gyarados get a real alternate texture in Stadium, because their shiny
|
||||
-- moves one region a long way and leaves another alone -- Jigglypuff's body
|
||||
-- stays pink while its irises go green -- and a single rotation moves
|
||||
-- everything or nothing.
|
||||
--
|
||||
-- Those five carry `lut` instead: an explicit before/after colour mapping,
|
||||
-- sampled from the verified texture pairs, listing only the colours that
|
||||
-- actually move. A first attempt drove them from a handful of per-region
|
||||
-- anchors and picked the nearest one per pixel, which is wrong in a way
|
||||
-- worth recording: with regions as far apart as Clefairy's pink body and
|
||||
-- its green ear tips, a dark red shadow pixel is "nearest" to the green and
|
||||
-- gets rotated 150 degrees the wrong way. The fixture caught it at a
|
||||
-- 124/255 channel error. An exact table is a few tens of kilobytes and has
|
||||
-- no such failure mode, so these five are data rather than algorithm.
|
||||
--
|
||||
-- WHY THIS RUNS AT EXTRACTION. The textures are already decoded to RGBA in
|
||||
-- memory at that moment (StadiumFragment.decodeTexture), and -- the part
|
||||
-- that matters -- generated effect frames are still distinguishable there.
|
||||
-- StadiumFx marks them `generated = true`, and the packer drops that field,
|
||||
-- so at runtime an additive flame can only be inferred back from the prim
|
||||
-- table. Recolouring a flame is wrong: a shiny Charizard has a shiny hide
|
||||
-- and an ordinary fire. Doing the work while the marker still exists means
|
||||
-- the discrimination is exact rather than reconstructed.
|
||||
--
|
||||
-- THE MEMO IS WHAT MAKES IT AFFORDABLE. These are N64 textures: a few
|
||||
-- hundred distinct colours across tens of thousands of texels. Converting
|
||||
-- per DISTINCT COLOUR instead of per pixel turns the inner loop into a
|
||||
-- table lookup, which is the difference between a pass that is felt during
|
||||
-- the install and one that is not.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ShinyPalette = {}
|
||||
|
||||
local floor, min, max, abs = math.floor, math.min, math.max, math.abs
|
||||
local byte, char, concat = string.byte, string.char, table.concat
|
||||
|
||||
-- ------- HSL
|
||||
|
||||
local function rgbToHsl(r, g, b)
|
||||
r, g, b = r / 255, g / 255, b / 255
|
||||
local mx, mn = max(r, g, b), min(r, g, b)
|
||||
local l = (mx + mn) / 2
|
||||
if mx == mn then return 0, 0, l end -- achromatic: hue is undefined
|
||||
local d = mx - mn
|
||||
local s = l > 0.5 and d / (2 - mx - mn) or d / (mx + mn)
|
||||
local h
|
||||
if mx == r then
|
||||
h = (g - b) / d + (g < b and 6 or 0)
|
||||
elseif mx == g then
|
||||
h = (b - r) / d + 2
|
||||
else
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
return h * 60, s, l
|
||||
end
|
||||
|
||||
local function hue2rgb(p, q, t)
|
||||
if t < 0 then t = t + 1 end
|
||||
if t > 1 then t = t - 1 end
|
||||
if t < 1 / 6 then return p + (q - p) * 6 * t end
|
||||
if t < 1 / 2 then return q end
|
||||
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
|
||||
return p
|
||||
end
|
||||
|
||||
local function hslToRgb(h, s, l)
|
||||
if s <= 0 then
|
||||
local v = floor(l * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
h = (h % 360) / 360
|
||||
local q = l < 0.5 and l * (1 + s) or l + s - l * s
|
||||
local p = 2 * l - q
|
||||
return floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5)
|
||||
end
|
||||
|
||||
-- GIMP's two curves, shared by the slide and the anchor paths so both
|
||||
-- reach the same colour from the same k.
|
||||
local function shiftSat(s, k)
|
||||
if k == 0 then return s end
|
||||
return max(0, min(1, s * (1 + k)))
|
||||
end
|
||||
|
||||
local function shiftLight(l, k)
|
||||
if k == 0 then return l end
|
||||
if k < 0 then return max(0, l * (1 + k)) end
|
||||
return min(1, l + k * (1 - l))
|
||||
end
|
||||
|
||||
-- ------- the two kinds of transform
|
||||
|
||||
-- A whole-model slide: the 146 species Stadium recolours this way.
|
||||
local function slideFn(slide)
|
||||
local dh = slide.h or 0
|
||||
local ks = (slide.s or 0) * 0.125
|
||||
local kl = (slide.l or 0) * 0.125
|
||||
return function(r, g, b)
|
||||
local h, s, l = rgbToHsl(r, g, b)
|
||||
-- An achromatic pixel has no hue to rotate and no saturation to scale;
|
||||
-- only a lightness step can reach it. Returning early is not just
|
||||
-- speed, it is exactness: round-tripping grey through HSL and back can
|
||||
-- move it by a unit, and a tooth that drifts is a visible defect.
|
||||
if s <= 0 then
|
||||
if kl == 0 then return r, g, b end
|
||||
local v = floor(shiftLight(l, kl) * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
return hslToRgb(h + dh, shiftSat(s, ks), shiftLight(l, kl))
|
||||
end
|
||||
end
|
||||
|
||||
-- An exact colour mapping: the five species Stadium gives a real second
|
||||
-- texture. A colour absent from the table is one the alternate texture left
|
||||
-- alone, so passing it straight through is the correct answer, not a
|
||||
-- fallback -- that is how Wigglytuff keeps its white belly and its black
|
||||
-- inner ears while its body moves to lilac.
|
||||
local function lutFn(lut)
|
||||
return function(r, g, b)
|
||||
local hit = lut[r * 65536 + g * 256 + b]
|
||||
if not hit then return r, g, b end
|
||||
return floor(hit / 65536) % 256, floor(hit / 256) % 256, hit % 256
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the colour table
|
||||
--
|
||||
-- Loaded lazily and cached. Two paths on purpose: through the mod namespace
|
||||
-- when the mod is running, and straight off disk when it is not. The
|
||||
-- extraction byte-diff (tests/stadium_extract_test.lua) stubs V with only
|
||||
-- `require` and `mod.log`, and the recolour has to be exercisable under
|
||||
-- exactly that harness -- a colour transform that can only run inside a
|
||||
-- live LOVE process is a colour transform nobody will test.
|
||||
local colors = nil
|
||||
|
||||
local function loadColors()
|
||||
if colors ~= nil then return colors or nil end
|
||||
if V and V.data then
|
||||
local ok, t = pcall(V.data, "shiny_colors")
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
-- Off disk, RELATIVE TO THE MOD rather than to the working directory.
|
||||
-- V.path is the mod's own directory (main.lua sets it; the headless
|
||||
-- harnesses set it to whatever --mod they were given). Guessing from the
|
||||
-- cwd instead is what made this silently find nothing when the extraction
|
||||
-- test was run from the project root rather than from the mod: every
|
||||
-- species built, none recoloured, and a PASS at the end of it.
|
||||
local tries = {}
|
||||
if V and V.path then tries[#tries + 1] = V.path .. "/data/shiny_colors.lua" end
|
||||
tries[#tries + 1] = "data/shiny_colors.lua"
|
||||
tries[#tries + 1] = "mods/DramaticShapeVoxelMod/data/shiny_colors.lua"
|
||||
for _, p in ipairs(tries) do
|
||||
local chunk = loadfile(p)
|
||||
if chunk then
|
||||
local ok, t = pcall(chunk)
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
end
|
||||
colors = false -- cache the miss; do not retry the disk per species
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the colour table was found at all. The extraction asks so it can
|
||||
-- say "no colours" once and loudly, rather than reporting 151 successful
|
||||
-- builds with no shiny variant among them.
|
||||
function ShinyPalette.haveColors()
|
||||
return loadColors() ~= nil
|
||||
end
|
||||
|
||||
-- The spec for one dex number, or nil if we have nothing for it.
|
||||
function ShinyPalette.forDex(dex)
|
||||
local all = loadColors()
|
||||
return all and all[dex] or nil
|
||||
end
|
||||
|
||||
-- Build the pixel transform for one species' spec, or nil when there is
|
||||
-- nothing to do.
|
||||
function ShinyPalette.transform(spec)
|
||||
if type(spec) ~= "table" then return nil end
|
||||
if spec.lut then
|
||||
if next(spec.lut) == nil then return nil end
|
||||
return lutFn(spec.lut)
|
||||
end
|
||||
local s = spec.slide
|
||||
if not s then return nil end
|
||||
if (s.h or 0) == 0 and (s.l or 0) == 0 and (s.s or 0) == 0 then return nil end
|
||||
return slideFn(s)
|
||||
end
|
||||
|
||||
-- ------- the pass over one texture
|
||||
--
|
||||
-- Memoised per distinct colour (see the header). The key packs RGB into one
|
||||
-- integer because a table with 24-bit integer keys is a flat array probe,
|
||||
-- where a "r,g,b" string key would allocate on every pixel -- and allocation
|
||||
-- inside a multi-million-iteration loop is the whole cost.
|
||||
--
|
||||
-- Alpha is copied through untouched, never premultiplied and never
|
||||
-- recomputed: the transform is defined on colour alone, and a shiny
|
||||
-- Gastly's soft edge must stay exactly as soft as it was.
|
||||
function ShinyPalette.recolorTexels(rgba, fn)
|
||||
local n = #rgba
|
||||
if n == 0 or not fn then return rgba end
|
||||
local memo = {}
|
||||
local out, blocks = {}, {}
|
||||
local bi = 0
|
||||
for i = 1, n, 4 do
|
||||
local r, g, b, a = byte(rgba, i, i + 3)
|
||||
local key = r * 65536 + g * 256 + b
|
||||
local hit = memo[key]
|
||||
if not hit then
|
||||
local nr, ng, nb = fn(r, g, b)
|
||||
hit = { nr, ng, nb }
|
||||
memo[key] = hit
|
||||
end
|
||||
bi = bi + 1
|
||||
blocks[bi] = char(hit[1], hit[2], hit[3], a)
|
||||
-- flushed in blocks so the concat never walks a table with millions of
|
||||
-- one-texel strings in it
|
||||
if bi >= 4096 then
|
||||
out[#out + 1] = concat(blocks)
|
||||
blocks, bi = {}, 0
|
||||
end
|
||||
end
|
||||
if bi > 0 then out[#out + 1] = concat(blocks, "", 1, bi) end
|
||||
return concat(out)
|
||||
end
|
||||
|
||||
-- ------- a tint, for the flat sprites
|
||||
--
|
||||
-- The 3D models get real recoloured texels. The 2D battle pics cannot: the
|
||||
-- engine bakes a species palette into a cached image keyed by path and
|
||||
-- palette name, and that cache has no idea which INDIVIDUAL is being drawn.
|
||||
-- What is available per-draw is the draw colour, which multiplies.
|
||||
--
|
||||
-- So the pic is tinted, and the tint is derived from the species' OWN shiny
|
||||
-- slide rather than being a generic gold: run a spread of reference colours
|
||||
-- through the real transform, take the mean ratio out to in, and that is
|
||||
-- the multiply that best stands in for it. A shiny Golbat leans green, a
|
||||
-- shiny Charizard goes dusky, and neither is a costume.
|
||||
--
|
||||
-- ITS ONE LIMIT, stated plainly: a multiply can only darken. Where a species'
|
||||
-- shiny is LIGHTER than its normal, the honest ratio is above 1 and gets
|
||||
-- clamped, so those come out under-shifted -- present, but quieter than the
|
||||
-- model. The floor keeps the darkest cases readable rather than muddy.
|
||||
local TINT_FLOOR = 0.45
|
||||
local tintCache = {}
|
||||
|
||||
-- Mid-tone references across the wheel. Deliberately not greys: the slide
|
||||
-- is multiplicative in saturation, so a grey reference would report no
|
||||
-- change for every species and hand back a tint of 1,1,1.
|
||||
local REFS = {
|
||||
{ 200, 90, 70 }, { 200, 150, 70 }, { 190, 190, 80 }, { 90, 180, 90 },
|
||||
{ 80, 170, 170 }, { 80, 120, 200 }, { 140, 90, 190 }, { 190, 90, 150 },
|
||||
}
|
||||
|
||||
function ShinyPalette.tintFor(dex)
|
||||
local hit = tintCache[dex]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
local fn = ShinyPalette.transform(spec)
|
||||
if not fn then tintCache[dex] = false; return nil end
|
||||
local sr, sg, sb, n = 0, 0, 0, 0
|
||||
|
||||
if spec.lut then
|
||||
-- A lookup table answers only the colours that are IN it, so running
|
||||
-- synthetic references through one returns them untouched and reports a
|
||||
-- tint of exactly 1 -- i.e. no tint, for the five species whose shiny is
|
||||
-- the most dramatic in the game. (A shiny Gyarados came out with an
|
||||
-- ordinary blue pic for precisely this reason.) The table's own entries
|
||||
-- are the right sample: they are what this Pokemon is actually made of.
|
||||
for from, to in pairs(spec.lut) do
|
||||
local fr, fg, fb = floor(from / 65536) % 256, floor(from / 256) % 256,
|
||||
from % 256
|
||||
local tr, tg, tb = floor(to / 65536) % 256, floor(to / 256) % 256,
|
||||
to % 256
|
||||
-- guard the near-black entries: a ratio against 2 is noise, and a
|
||||
-- handful of them would swamp the mean
|
||||
if fr > 24 and fg > 24 and fb > 24 then
|
||||
sr = sr + tr / fr
|
||||
sg = sg + tg / fg
|
||||
sb = sb + tb / fb
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A slide: measure it against the colour this Pokemon is mostly MADE of.
|
||||
--
|
||||
-- Averaging over a balanced set of references does not work, and the
|
||||
-- reason is worth keeping: a hue rotation moves red toward cyan and cyan
|
||||
-- toward red, so over a symmetric wheel the ratios cancel and every
|
||||
-- species reports a tint of 1. Charizard and Ponyta both came back with no
|
||||
-- tint at all that way. One real body colour, rotated, is the whole
|
||||
-- answer.
|
||||
if n == 0 and spec.dom then
|
||||
local dr = floor(spec.dom / 65536) % 256
|
||||
local dg = floor(spec.dom / 256) % 256
|
||||
local db = spec.dom % 256
|
||||
if dr > 12 and dg > 12 and db > 12 then
|
||||
local r, g, b = fn(dr, dg, db)
|
||||
sr, sg, sb, n = r / dr, g / dg, b / db, 1
|
||||
end
|
||||
end
|
||||
|
||||
if n == 0 then
|
||||
for _, c in ipairs(REFS) do
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
sr = sr + r / c[1]
|
||||
sg = sg + g / c[2]
|
||||
sb = sb + b / c[3]
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
local t = {
|
||||
max(TINT_FLOOR, min(1, sr / n)),
|
||||
max(TINT_FLOOR, min(1, sg / n)),
|
||||
max(TINT_FLOOR, min(1, sb / n)),
|
||||
}
|
||||
-- a tint that came out as no tint at all is worse than none: it costs a
|
||||
-- colour-hook wrap per draw and changes nothing
|
||||
if t[1] > 0.995 and t[2] > 0.995 and t[3] > 0.995 then
|
||||
tintCache[dex] = false
|
||||
return nil
|
||||
end
|
||||
tintCache[dex] = t
|
||||
return t
|
||||
end
|
||||
|
||||
-- A transform for PALETTE colours rather than texture texels.
|
||||
--
|
||||
-- The two are not the same job, and using the texel transform on a palette
|
||||
-- quietly does nothing for five species. A lookup table answers only the
|
||||
-- colours that are in it -- the ones its model is painted with -- and the
|
||||
-- engine's ADVANCED palettes are a different set of colours entirely
|
||||
-- (BLUEMON's blue is not any blue on the Gyarados model). Asked to shift a
|
||||
-- palette, the table therefore returns it unchanged, and the most dramatic
|
||||
-- shiny in the game comes out identical.
|
||||
--
|
||||
-- So: slide species use the slide, which is defined on all colours. Table
|
||||
-- species fall back to their tint multiplier, which IS derived from the
|
||||
-- table and does carry its direction.
|
||||
-- ------- reading a SLIDE back out of a lookup table
|
||||
--
|
||||
-- A multiply was the first answer here and it is not good enough. Gyarados is
|
||||
-- the whole argument: its shiny is BLUE TURNING RED, and no multiply reaches
|
||||
-- red from blue -- it can only darken what is already there, so the most
|
||||
-- dramatic shiny in the game came out a dull mauve. That is the same ceiling
|
||||
-- the flat tint hit (see lib/ShinyPics.lua), reached from the other side.
|
||||
--
|
||||
-- But the table is not just a direction, it is the ANSWER: 1857 exact
|
||||
-- (normal -> shiny) pairs lifted from Stadium's own alternate textures. Read
|
||||
-- as HSL, each pair is a hue rotation, a saturation scale and a lightness
|
||||
-- step -- which is precisely the shape of a slide. So the five table species
|
||||
-- get a slide MEASURED from their own table rather than declared, and the one
|
||||
-- transform serves all 151.
|
||||
--
|
||||
-- Averaged over the pairs because a real alternate texture is not a perfect
|
||||
-- slide -- that is why it is a texture -- but it is close enough to one that
|
||||
-- the mean carries the change a player actually sees.
|
||||
--
|
||||
-- hue circularly (sum the unit vectors), or opposite rotations
|
||||
-- would cancel to "no change"
|
||||
-- saturation as GIMP's k, s2 = s1 * (1 + k), skipping near-grey pairs
|
||||
-- where the ratio is noise
|
||||
-- lightness as GIMP's two-sided k, matching shiftLight
|
||||
local slideCache = {}
|
||||
|
||||
local function slideFromLut(lut)
|
||||
local sx, sy, hueN = 0, 0, 0
|
||||
local sk, sn, lk, ln = 0, 0, 0, 0
|
||||
for key, val in pairs(lut) do
|
||||
local r1 = floor(key / 65536) % 256
|
||||
local g1 = floor(key / 256) % 256
|
||||
local b1 = key % 256
|
||||
local r2 = floor(val / 65536) % 256
|
||||
local g2 = floor(val / 256) % 256
|
||||
local b2 = val % 256
|
||||
local h1, s1, l1 = rgbToHsl(r1, g1, b1)
|
||||
local h2, s2, l2 = rgbToHsl(r2, g2, b2)
|
||||
-- an achromatic end has no hue, so the pair says nothing about rotation
|
||||
if s1 > 0.08 and s2 > 0.08 then
|
||||
-- DEGREES, both of them: rgbToHsl returns h*60 and hslToRgb takes
|
||||
-- `h % 360`, so the declared slides are in degrees too (-136 for
|
||||
-- Charizard) and a measured one has to come out in the same unit. It
|
||||
-- did not at first, and a rotation of 0.13 TURNS read as 0.13 degrees:
|
||||
-- Gyarados stayed blue and the whole point of measuring was lost.
|
||||
local d = math.rad(h2 - h1)
|
||||
sx, sy = sx + math.cos(d), sy + math.sin(d)
|
||||
hueN = hueN + 1
|
||||
sk, sn = sk + (s2 / s1 - 1), sn + 1
|
||||
end
|
||||
if l1 > 0.02 and l1 < 0.98 then
|
||||
lk = lk + (l2 < l1 and (l2 / l1 - 1) or ((l2 - l1) / (1 - l1)))
|
||||
ln = ln + 1
|
||||
end
|
||||
end
|
||||
local dh = 0
|
||||
if hueN > 0 and (sx * sx + sy * sy) > 1e-9 then
|
||||
dh = math.deg(math.atan2(sy, sx))
|
||||
end
|
||||
-- back into the -8..+8 STEPS the slide fields are in, so the value that
|
||||
-- comes out of here is the same kind of number as the 146 declared ones
|
||||
return {
|
||||
h = dh,
|
||||
s = sn > 0 and (sk / sn) / 0.125 or 0,
|
||||
l = ln > 0 and (lk / ln) / 0.125 or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- A transform for PALETTE colours rather than texture texels.
|
||||
--
|
||||
-- The two are not the same job. A lookup table answers only the colours that
|
||||
-- are IN it -- the ones its model is painted with -- and the engine's palettes
|
||||
-- are a different set entirely (BLUEMON's blue is not any blue on the
|
||||
-- Gyarados model), so the table asked to shift a palette returns it unchanged
|
||||
-- and the most dramatic shiny in the game comes out identical.
|
||||
--
|
||||
-- So: slide species use their declared slide, and table species use one
|
||||
-- measured out of their table by slideFromLut above. Both end up in the same
|
||||
-- HSL transform, which is the only kind that can rotate a hue.
|
||||
-- ------- and why the LIGHTNESS step is damped on a palette
|
||||
--
|
||||
-- A slide's l is authored against a TEXTURE: thousands of texels spread
|
||||
-- across the middle of the range, where "six steps darker" reads as a shadow
|
||||
-- falling over the animal. A Game Boy palette is not that. It is a four-shade
|
||||
-- RAMP from paper to ink, and only the middle two shades are the Pokemon --
|
||||
-- both already dark relative to the white they sit on, and both needing to
|
||||
-- stay clear of the fixed ink below them.
|
||||
--
|
||||
-- Applied whole, Golbat's -6 took its two shades to 27,42,37 and 34,58,52:
|
||||
-- correct green, and a green nobody can see against a 25,16,16 outline. Half
|
||||
-- the step keeps the direction and keeps the pic readable, which is the trade
|
||||
-- the ramp forces. Hue and saturation are untouched -- they are what makes a
|
||||
-- shiny recognisable as one, and neither collides with the paper or the ink.
|
||||
ShinyPalette.PALETTE_LIGHT_DAMP = 0.5
|
||||
|
||||
function ShinyPalette.paletteTransform(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
local slide = spec and spec.slide
|
||||
if not spec then return nil end
|
||||
if spec.lut then
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
slide = slideCache[dex] or nil
|
||||
end
|
||||
if not slide then return nil end
|
||||
return slideFn({
|
||||
h = slide.h or 0,
|
||||
s = slide.s or 0,
|
||||
l = (slide.l or 0) * ShinyPalette.PALETTE_LIGHT_DAMP,
|
||||
})
|
||||
end
|
||||
|
||||
-- The measured slide itself, for the tests and for anyone checking the five
|
||||
-- against Stadium's own textures.
|
||||
function ShinyPalette.lutSlide(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
if not (spec and spec.lut) then return nil end
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
return slideCache[dex] or nil
|
||||
end
|
||||
|
||||
-- ------- the pass over one species' whole texture array
|
||||
|
||||
-- Recolour `textures` in place, skipping the ones that must not move.
|
||||
--
|
||||
-- Two exclusions, both load-bearing:
|
||||
--
|
||||
-- generated / index == -1 StadiumFx's flipbook frames -- flames, beams,
|
||||
-- sparks. A shiny Pokemon has a shiny hide and
|
||||
-- an ordinary fire; tinting the attack effects
|
||||
-- would read as a bug. This marker exists ONLY
|
||||
-- here, which is why the recolour lives at
|
||||
-- extraction (see the header).
|
||||
-- w or h of zero a degenerate slot with nothing to transform.
|
||||
--
|
||||
-- Returns the number of textures actually touched, so the caller can tell a
|
||||
-- species that recoloured from one that silently did not.
|
||||
function ShinyPalette.recolorTextures(textures, spec)
|
||||
local fn = ShinyPalette.transform(spec)
|
||||
if not fn then return 0 end
|
||||
local touched = 0
|
||||
for i = 1, #textures do
|
||||
local t = textures[i]
|
||||
local skip = t.generated == true or t.index == -1
|
||||
or not t.w or not t.h or t.w == 0 or t.h == 0
|
||||
if not skip and t.rgba and #t.rgba > 0 then
|
||||
t.rgba = ShinyPalette.recolorTexels(t.rgba, fn)
|
||||
touched = touched + 1
|
||||
end
|
||||
end
|
||||
return touched
|
||||
end
|
||||
|
||||
return ShinyPalette
|
||||
@@ -0,0 +1,156 @@
|
||||
-- A shiny's battle pic, genuinely recoloured.
|
||||
--
|
||||
-- ------- why the tint had to go
|
||||
--
|
||||
-- The first answer to "a shiny on the flat art" was a MULTIPLY at draw time,
|
||||
-- and it was the wrong shape twice over:
|
||||
--
|
||||
-- * A multiply can only DARKEN. Shiny Gyarados is blue turning RED; the
|
||||
-- nearest a multiply gets is a dimmer blue. Every species whose shiny is
|
||||
-- lighter, or is a hue rotation rather than a dimming, came out looking
|
||||
-- like the ordinary one with the brightness down -- which is exactly what
|
||||
-- "shinies don't work in 2D" describes.
|
||||
-- * It tinted the whole PICS LAYER, both sides at once, because that is the
|
||||
-- granularity the engine's own draw has. A shiny facing an ordinary mon
|
||||
-- dimmed its opponent too.
|
||||
--
|
||||
-- ------- where the colour actually lives
|
||||
--
|
||||
-- The battle pic is not drawn from four-shade art at play time. getImage
|
||||
-- (src/battle/BattleState.lua:147) snaps the four DMG shades to the species'
|
||||
-- palette ONCE, with mapPixel, and caches the finished image under
|
||||
-- `path .. "#" .. pal.name`. By the time anything is drawn the colour is
|
||||
-- already baked in, and the only way to change it is to hand that bake a
|
||||
-- different palette -- which also means a different cache NAME, or the shiny
|
||||
-- and the ordinary pic fight over one cache slot.
|
||||
--
|
||||
-- That is the whole of this file. It is the same conclusion ShinyUI reached
|
||||
-- for the status screen ("the palette is what has to move"), applied to the
|
||||
-- one other place a Pokemon is drawn flat.
|
||||
--
|
||||
-- ------- the seam
|
||||
--
|
||||
-- monPalette (BattleState.lua:216) is a local, so it cannot be wrapped. What
|
||||
-- it calls -- PaletteFX.monPal and PaletteFX.monPalName -- are not, and they
|
||||
-- are asked in that order for every battle pic the game builds.
|
||||
--
|
||||
-- Neither is told WHICH Pokemon is being drawn; both take a species. The
|
||||
-- individual arrives one call earlier, at the engine's own `pokemon.sprite`
|
||||
-- hook, which carries ctx.mon -- so the hook notes "the pic about to be built
|
||||
-- is this shiny mon's" and the two palette wraps consume that note. A flag
|
||||
-- rather than an argument, because the argument does not exist.
|
||||
--
|
||||
-- It is consumed ONCE, and matched on species as well, so a leak (monPalette
|
||||
-- returns early when a species has no palette at all, and then never asks for
|
||||
-- the name) cannot recolour somebody else's pic -- the worst case is one
|
||||
-- extra ordinary pic built under a shiny cache key, which the next call
|
||||
-- corrects.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local ShinyPics = {}
|
||||
|
||||
-- { species = <name>, dex = <n> } while a shiny's pic is being built
|
||||
local pending = nil
|
||||
|
||||
-- The suffix that makes the shiny pic its own cache entry. Part of the
|
||||
-- palette NAME rather than the path, because the name is what getImage keys
|
||||
-- on and the path is real art on disk that this mod does not add to.
|
||||
ShinyPics.SUFFIX = "-SHINY"
|
||||
|
||||
-- ------- what the sprite hook notices
|
||||
--
|
||||
-- Called for every battle pic the engine resolves. Returns nothing: the point
|
||||
-- is the note it leaves.
|
||||
function ShinyPics.note(ctx)
|
||||
pending = nil
|
||||
if type(ctx) ~= "table" or ctx.kind ~= "battle" then return end
|
||||
local mon = ctx.mon
|
||||
if not (mon and Shiny.isShiny(mon)) then return end
|
||||
local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return end
|
||||
pending = { species = ctx.species, dex = dex }
|
||||
end
|
||||
|
||||
-- Whether the pic currently being built is a shiny's -- for a test, and for
|
||||
-- the palette wraps below.
|
||||
function ShinyPics.pendingDex(species)
|
||||
if pending and pending.species == species then return pending.dex end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the palette wraps
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses.
|
||||
function ShinyPics.install()
|
||||
local ok, PaletteFX = pcall(require, "src.render.PaletteFX")
|
||||
if not ok or type(PaletteFX) ~= "table" then return false end
|
||||
if PaletteFX.dramaticShapeShiny then return true end
|
||||
local innerPal = PaletteFX.monPal
|
||||
local innerName = PaletteFX.monPalName
|
||||
if type(innerPal) ~= "function" or type(innerName) ~= "function" then
|
||||
return false
|
||||
end
|
||||
|
||||
function PaletteFX.monPal(data, species, transformed, ...)
|
||||
local cols = innerPal(data, species, transformed, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
if not (cols and dex) then
|
||||
-- nothing to recolour, and monPalette's early return means the name
|
||||
-- wrap below may never run: drop the note here rather than leave it
|
||||
-- for whoever asks next
|
||||
if not cols then pending = nil end
|
||||
return cols
|
||||
end
|
||||
local fn = ShinyPalette.paletteTransform(dex)
|
||||
if not fn then return cols end
|
||||
-- ------- the first and last shades DO NOT MOVE
|
||||
--
|
||||
-- A Game Boy mon palette is four shades and only the middle two are the
|
||||
-- Pokemon. The first is the PAPER -- 255,239,255 in every species'
|
||||
-- palette in the dataset, the white the pic sits on -- and the last is
|
||||
-- the INK, 25,16,16, the outline every pic is drawn with. Both are shared
|
||||
-- constants, not colours anybody chose for this animal.
|
||||
--
|
||||
-- Sliding them is what a shiny looks like when it is broken: shiny Golbat
|
||||
-- rotates far enough that its white became NAVY (31,34,93) and the pic
|
||||
-- read as a mon on a blue card rather than a green Golbat. Stadium's
|
||||
-- slides were authored for model textures, which have no paper and no
|
||||
-- outline in them, so there was nothing there to warn against it.
|
||||
--
|
||||
-- COPIED, never written through, for the rest. monPal hands back the
|
||||
-- dataset's own palette table, and mutating it would recolour every
|
||||
-- Pokemon of the species everywhere for the rest of the process -- the
|
||||
-- same trap ShinyUI's summary wrap documents.
|
||||
local last = #cols
|
||||
local out = {}
|
||||
for i, c in ipairs(cols) do
|
||||
if type(c) == "table" and c[1] and i > 1 and i < last then
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
out[i] = { r, g, b }
|
||||
else
|
||||
out[i] = c
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PaletteFX.monPalName(data, species, ...)
|
||||
local name = innerName(data, species, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
pending = nil -- consumed: one pic, one note
|
||||
if not (name and dex) then return name end
|
||||
if not ShinyPalette.paletteTransform(dex) then return name end
|
||||
return name .. ShinyPics.SUFFIX
|
||||
end
|
||||
|
||||
PaletteFX.dramaticShapeShiny = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyPics
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
-- Where a shiny SHOWS on the flat art: the battle pics and the status page.
|
||||
--
|
||||
-- The Stadium models carry genuinely recoloured texels (see ShinyPalette and
|
||||
-- the extraction). Everything drawn as a Game Boy pic cannot, because the
|
||||
-- engine bakes a species palette into an image cache keyed by path and
|
||||
-- palette name -- a cache with no notion of WHICH Rattata is being drawn. So
|
||||
-- the flat side is answered two ways:
|
||||
--
|
||||
-- the battle pic tinted at draw time, per side, with the multiply
|
||||
-- ShinyPalette.tintFor derives from that species' own
|
||||
-- shiny slide. Under ADVANCED (`redpp`, the pokered-gbc
|
||||
-- colour pack) the pic is at its most colourful and the
|
||||
-- tint reads clearly; under the DMG modes there is
|
||||
-- barely any colour to shift, which is why the status
|
||||
-- page also carries a plain, mode-proof MARK.
|
||||
-- the status page a star beside the level, drawn in the engine's own
|
||||
-- GB pixel grid so it is palette-processed like every
|
||||
-- other pixel on the screen rather than floating over
|
||||
-- the finished frame.
|
||||
--
|
||||
-- Both are monkeypatches, idempotent by sentinel, the pattern the rest of
|
||||
-- this mod uses (OverworldBattle.install, Stadium.install). The summary
|
||||
-- screen has no hook at all -- there is no `ui.summary.*` anywhere in the
|
||||
-- engine -- so a wrap is the only route to it, and it is deliberately a thin
|
||||
-- one: draw the engine's screen, then add one glyph.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local ShinyUI = {}
|
||||
|
||||
-- ------- the tint, applied to one draw
|
||||
--
|
||||
-- Lifted in shape from OverworldBattle.withTint, and for the same reason it
|
||||
-- exists there: the pics layer sets its own colour many times over as it
|
||||
-- draws (the faint slide's fade, the damage blink), so the way to tint the
|
||||
-- result without clobbering any of that is to multiply every colour it sets
|
||||
-- on its way past. Restored unconditionally, including on error, because a
|
||||
-- leaked setColor would tint the entire rest of the frame.
|
||||
function ShinyUI.withTint(tint, fn, ...)
|
||||
if not tint then return fn(...) end
|
||||
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
|
||||
local gfx = love.graphics
|
||||
local setColor = gfx.setColor
|
||||
gfx.setColor = function(cr, cg, cb, ca, ...)
|
||||
if type(cr) == "table" then
|
||||
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
|
||||
cr[4] }, cg, ...)
|
||||
end
|
||||
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
|
||||
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
|
||||
end
|
||||
local ok, err = pcall(fn, ...)
|
||||
gfx.setColor = setColor
|
||||
setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- The tint for a mon, or nil when it is not shiny or we have no colours.
|
||||
function ShinyUI.tintFor(mon, data)
|
||||
if not (mon and Shiny.isShiny(mon)) then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return nil end
|
||||
return ShinyPalette.tintFor(dex)
|
||||
end
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Drawn as rectangles rather than as a font character because the Game Boy
|
||||
-- charmap has no star, and a letter would read as a typo. Four spokes and a
|
||||
-- centre, in the same near-black the screen's text uses, so the palette pass
|
||||
-- treats it exactly like a glyph -- under ADVANCED and under every DMG mode
|
||||
-- alike.
|
||||
--
|
||||
-- Seven pixels square, which is the largest that fits the gap beside the
|
||||
-- level without touching the DrawLineBox bracket at column 19.
|
||||
function ShinyUI.drawStar(px, py)
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
g.setColor(0, 0, 0, 1)
|
||||
-- vertical, horizontal, then the four diagonal nubs
|
||||
g.rectangle("fill", px + 3, py, 1, 7)
|
||||
g.rectangle("fill", px, py + 3, 7, 1)
|
||||
g.rectangle("fill", px + 1, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 1, py + 5, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 5, 1, 1)
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
|
||||
function ShinyUI.install()
|
||||
ShinyUI.installSummary()
|
||||
end
|
||||
|
||||
-- The status page. Wraps the draw and adds the star afterwards, so the
|
||||
-- engine's own layout is untouched and a layout change upstream costs us
|
||||
-- the glyph's position and nothing else.
|
||||
function ShinyUI.installSummary()
|
||||
local ok, SummaryMenu = pcall(require, "src.ui.SummaryMenu")
|
||||
if not ok or type(SummaryMenu) ~= "table" then return end
|
||||
if SummaryMenu.dramaticShapeShiny then return end
|
||||
local inner = SummaryMenu.draw
|
||||
if type(inner) ~= "function" then return end
|
||||
|
||||
function SummaryMenu:draw(...)
|
||||
local out = { inner(self, ...) }
|
||||
-- page 1 only: page 2 wipes the block the level sits in
|
||||
-- (status_screen.asm ClearScreenArea over (9,2)), so a mark left there
|
||||
-- would be half-erased by the engine's own clear.
|
||||
if self.page == 1 and Shiny.isShiny(self.mon) then
|
||||
-- beside PrintLevel at (14,2): column 13, row 2, in the gap the
|
||||
-- level's own leading space leaves
|
||||
pcall(ShinyUI.drawStar, 104, 17)
|
||||
end
|
||||
return unpack(out)
|
||||
end
|
||||
|
||||
-- The summary PIC, through its PALETTE.
|
||||
--
|
||||
-- Recolouring the sprite's pixels here does NOT work, and it is worth
|
||||
-- writing down why rather than leaving it to be re-attempted: the summary
|
||||
-- art is four-shade DMG grey, and the screen's colour is applied
|
||||
-- afterwards by the palette pass over the finished frame. Whatever RGB is
|
||||
-- put in the ImageData is remapped away by it. The colour of that pic
|
||||
-- lives in the palette and nowhere else, so the palette is what has to
|
||||
-- move. (Tried it, shot it, reverted it.)
|
||||
--
|
||||
-- This is the ADVANCED-palette answer, and it is better than a multiply:
|
||||
-- SetPal_StatusScreen hands the pic zone the species palette
|
||||
-- (PaletteFX.monPal), and sgbPalettes is a method on the MENU, so unlike
|
||||
-- the battle pic's image cache it knows which individual is on screen.
|
||||
-- Running those four colours through the species' own shiny transform
|
||||
-- gives the summary a genuinely recoloured Pokemon -- brightening
|
||||
-- included, which a draw-colour multiply cannot do.
|
||||
--
|
||||
-- Only ZONE entries are touched. The first palette the engine returns is
|
||||
-- the whole-screen HP-bar one, and rotating that would recolour the text.
|
||||
local innerPal = SummaryMenu.sgbPalettes
|
||||
if type(innerPal) == "function" then
|
||||
function SummaryMenu:sgbPalettes(game, ...)
|
||||
local out = innerPal(self, game, ...)
|
||||
if type(out) ~= "table" or not Shiny.isShiny(self.mon) then return out end
|
||||
local def = game and game.data and game.data.pokemon
|
||||
and game.data.pokemon[self.mon.species]
|
||||
local fn = def and def.dex
|
||||
and ShinyPalette.transform(ShinyPalette.forDex(def.dex))
|
||||
if not fn then return out end
|
||||
for _, z in ipairs(out) do
|
||||
if type(z) == "table" and z.w and z.h and type(z.colors) == "table" then
|
||||
-- copied, never mutated in place: monPal hands back the dataset's
|
||||
-- own palette table, and writing through it would recolour every
|
||||
-- Pokemon of the species everywhere for the rest of the process
|
||||
local cols = {}
|
||||
for i, c in ipairs(z.colors) do
|
||||
if type(c) == "table" and c[1] then
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
cols[i] = { r, g, b }
|
||||
else
|
||||
cols[i] = c
|
||||
end
|
||||
end
|
||||
z.colors = cols
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
SummaryMenu.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- ------- the battle pics are NOT tinted here any more
|
||||
--
|
||||
-- There used to be a third wrap in this file: a multiply over
|
||||
-- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason
|
||||
-- is worth keeping so it is not put back.
|
||||
--
|
||||
-- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the
|
||||
-- nearest a multiply gets to that is a dimmer blue -- so every species whose
|
||||
-- shiny is lighter, or is a rotation rather than a dimming, read as the
|
||||
-- ordinary one with the brightness down. And the engine's pic layer draws
|
||||
-- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it.
|
||||
--
|
||||
-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where
|
||||
-- a battle pic's colour actually lives: getImage bakes the four DMG shades
|
||||
-- into the species palette once and caches the result, so handing that bake a
|
||||
-- shiny palette (under a cache name of its own) gives a genuinely recoloured
|
||||
-- pic -- brightening included -- for one side alone.
|
||||
--
|
||||
-- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for
|
||||
-- the per-side canvas, and they are the only tint left in the mod.
|
||||
|
||||
return ShinyUI
|
||||
+91
-3
@@ -51,6 +51,8 @@ local V = ...
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
local StadiumMon = V.require("StadiumMon")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyFx = V.require("ShinyFx")
|
||||
|
||||
local Stadium = {}
|
||||
|
||||
@@ -370,6 +372,15 @@ function Stadium.update(dt, battle, groundY)
|
||||
-- a fresh arrival: this Pokemon has not grown out of its ball yet
|
||||
if mon then mon.grow, mon.grewOwn = nil, nil end
|
||||
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
|
||||
-- and if it is shiny, announce it. This edge rather than the grow,
|
||||
-- because a WILD foe never grows -- it is on the field from the
|
||||
-- first frame -- and that is the encounter a shiny most wants to be
|
||||
-- announced on. See the header of ShinyFx.
|
||||
if ShinyBattle.battlerIsShiny(battler) then
|
||||
ShinyFx.arm(side)
|
||||
else
|
||||
ShinyFx.clear(side)
|
||||
end
|
||||
end
|
||||
-- the collapse this side is owed, once its bar has finished emptying
|
||||
if session.faintPending and session.faintPending[side] then
|
||||
@@ -381,16 +392,55 @@ function Stadium.update(dt, battle, groundY)
|
||||
end
|
||||
end
|
||||
|
||||
mon:setSpecies(dex)
|
||||
-- Shininess is a property of the OCCUPANT, not of the species, so it is
|
||||
-- resolved here beside the dex number and passed with it. A shiny
|
||||
-- Rattata and an ordinary one are the same dex and different models.
|
||||
--
|
||||
-- Read off the battler rather than remembered, because Transform makes
|
||||
-- the two disagree: a Ditto that copied a shiny Rattata wears the
|
||||
-- Rattata's dex (session.transform above) and keeps its OWN shininess,
|
||||
-- which is exactly what the games do.
|
||||
local shiny = battler ~= nil and not session.transform[side]
|
||||
and ShinyBattle.battlerIsShiny(battler)
|
||||
|
||||
mon:setSpecies(dex, shiny)
|
||||
-- and tell the pack cache this one is standing there, every frame. Its
|
||||
-- eviction order is keyed on LOADS, and a side only loads when its
|
||||
-- species changes -- so without this a Pokemon that has been out for a
|
||||
-- few turns is the least recently loaded thing in the cache and gets its
|
||||
-- textures released out from under it the moment a fifth species enters
|
||||
-- the battle (see StadiumPack.keep).
|
||||
if mon.species then StadiumPack.keep(mon.species) end
|
||||
-- the battle (see StadiumPack.keep). The shiny flag rides along: the
|
||||
-- shiny and normal models are separate cache entries.
|
||||
if mon.species then StadiumPack.keep(mon.species, mon.shiny) end
|
||||
|
||||
-- how big this Pokemon actually is, so a shiny's sparkle can be sized to
|
||||
-- it rather than to a constant that is wrong for most of the dex (see
|
||||
-- the header of ShinyFx). Pushed every frame: the model can arrive a
|
||||
-- frame or two after the burst is armed, and a send-out is still growing
|
||||
-- while it plays.
|
||||
if mon.rig and mon.model then
|
||||
ShinyFx.setMetrics(side, mon:worldHeight(), mon:worldRadius())
|
||||
else
|
||||
ShinyFx.setMetrics(side, nil)
|
||||
end
|
||||
|
||||
-- and let a waiting sparkle GO, once the fight is actually the thing on
|
||||
-- screen. The battle draws underneath the transition wipe for about a
|
||||
-- second before that, and a burst released then plays out its whole life
|
||||
-- behind it -- armed, drawn, counted, and never seen, which is exactly
|
||||
-- how this looked when it was keyed on the scene drawing instead.
|
||||
local g = game()
|
||||
local top = g and g.stack and g.stack:top()
|
||||
if top == battle then ShinyFx.release(side) end
|
||||
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
|
||||
and not (battler and battler.substituteHP)
|
||||
-- LET'S GO capture mode: the player's model is out of the shot the
|
||||
-- same way its card and back pic are (the shrink half of the story is
|
||||
-- below, AFTER the grow block, which reassigns mon.scale every frame)
|
||||
local cap = V.require("BattleScene").capture
|
||||
if side == "player" and cap and cap.hidePlayer then
|
||||
mon.visible = false
|
||||
end
|
||||
-- cleared up front, so a side that has just lost its rig cannot leave
|
||||
-- last frame's matrix behind it
|
||||
mon.model_matrix = nil
|
||||
@@ -425,6 +475,16 @@ function Stadium.update(dt, battle, groundY)
|
||||
local okG, grow = pcall(battle.growInScale, battle, battler)
|
||||
mon.scale = (okG and grow) or 1
|
||||
end
|
||||
-- LET'S GO capture: the foe drinking into the ball. AFTER the grow
|
||||
-- block on purpose -- that block reassigns mon.scale every frame,
|
||||
-- and the first cut of this hook sat above it and was silently
|
||||
-- clobbered: the model stood at full size over a ball that had
|
||||
-- supposedly swallowed it. The session's fraction owns the scale
|
||||
-- for as long as it exists; the frame it clears, the grow block
|
||||
-- above is already putting the engine's own answer back.
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
mon.scale = cap.shrink
|
||||
end
|
||||
mon:update(dt or 0)
|
||||
if mon.visible and arena then
|
||||
local cell = arena[side]
|
||||
@@ -491,6 +551,34 @@ function Stadium.guard(side, mon, what, fn)
|
||||
return false
|
||||
end
|
||||
|
||||
-- The foe's body for the capture mode's collision and ring, when a MODEL
|
||||
-- is standing there instead of a pic: its own measured height and
|
||||
-- footprint, in world pixels. A model stands on the ground, so the body's
|
||||
-- centre is half its height up. nil whenever no model covers the foe,
|
||||
-- which sends CatchThrow to its pic measurement instead.
|
||||
function Stadium.captureBody()
|
||||
if not session then return nil end
|
||||
local mon = session.enemy
|
||||
if not (mon and mon.rig and mon.visible) then return nil end
|
||||
local okH, h = pcall(mon.worldHeight, mon)
|
||||
if not (okH and h and h > 0) then return nil end
|
||||
-- The POSED body, when there is one: a flying Pokemon is nowhere near
|
||||
-- the mark its cell projects to, and only the pose knows where it went
|
||||
-- (StadiumMon:bodySpan). The bind-pose figures stand in until the
|
||||
-- first skin, which is right for everything that keeps its feet down.
|
||||
local okS, centre, half, girth = pcall(mon.bodySpan, mon)
|
||||
if okS and centre then
|
||||
return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))),
|
||||
yOff = centre,
|
||||
hh = math.max(4, half) }
|
||||
end
|
||||
local okR, r = pcall(mon.worldRadius, mon)
|
||||
local rr = (okR and r and r > 0) and r or h * 0.4
|
||||
return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))),
|
||||
yOff = h * 0.5,
|
||||
hh = math.max(4, h * 0.55) }
|
||||
end
|
||||
|
||||
function Stadium.draw(pull)
|
||||
if not session then return end
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
|
||||
+41
-2
@@ -26,6 +26,7 @@ local V = ...
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumFragment = V.require("StadiumFragment")
|
||||
local StadiumFx = V.require("StadiumFx")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local StadiumBuild = {}
|
||||
|
||||
@@ -661,7 +662,41 @@ function StadiumBuild.species(rom, fileno)
|
||||
local ctx = StadiumBuild.contextTable(rows, #data.anims)
|
||||
local bytes, height, floorY, radius =
|
||||
StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
return { species = species, bytes = bytes, height = height,
|
||||
|
||||
-- ------- and the shiny, from the same extraction
|
||||
--
|
||||
-- ORDER MATTERS AND IS THE WHOLE TRICK. The normal pack is written FIRST,
|
||||
-- off untouched texels, so `bytes` is bit-for-bit what it has always been
|
||||
-- and tests/stadium_extract_test.lua keeps diffing green against the
|
||||
-- Python oracle. Only then are the textures recoloured and the model
|
||||
-- packed a second time. The oracle knows nothing about shiny and does not
|
||||
-- need to: the format did not move, so there is no second implementation
|
||||
-- to keep in step and no DSM4.
|
||||
--
|
||||
-- Recolouring HERE rather than at load is what makes the effect textures
|
||||
-- separable. StadiumFx marks its generated frames `generated = true` and
|
||||
-- the packer drops the field, so this is the last moment a flame is
|
||||
-- distinguishable from a hide without inferring it back from the prim
|
||||
-- table. A shiny Charizard has a shiny hide and an ordinary fire.
|
||||
--
|
||||
-- Failure is not fatal: a species whose colours we lack, or a transform
|
||||
-- that throws, simply ships without a shiny variant and the runtime falls
|
||||
-- back to the normal model. Losing a recolour is a blemish; losing the
|
||||
-- install is a broken mod.
|
||||
local shinyBytes
|
||||
local ok, err = pcall(function()
|
||||
local spec = ShinyPalette.forDex(species)
|
||||
if not spec then return end
|
||||
if ShinyPalette.recolorTextures(data.textures, spec) == 0 then return end
|
||||
shinyBytes = StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
end)
|
||||
if not ok and V and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny recolour failed for species %d: %s",
|
||||
species, tostring(err))
|
||||
end
|
||||
|
||||
return { species = species, bytes = bytes, shinyBytes = shinyBytes,
|
||||
height = height,
|
||||
floor = floorY, radius = radius, bones = #data.bones,
|
||||
prims = #data.prims, anims = #data.anims,
|
||||
warnings = data.warnings }
|
||||
@@ -684,13 +719,17 @@ function StadiumBuild.job(rom, write, count)
|
||||
local fileno = self.done
|
||||
local ok, res, err = pcall(StadiumBuild.species, rom, fileno)
|
||||
if ok and res then
|
||||
local wrote, wErr = write(res.species, res.bytes)
|
||||
local wrote, wErr = write(res.species, res.bytes, res.shinyBytes)
|
||||
if not wrote then
|
||||
self.error = wErr or ("could not write species " .. res.species)
|
||||
self.done = self.total
|
||||
return false
|
||||
end
|
||||
self.bytes = self.bytes + #res.bytes
|
||||
if res.shinyBytes then
|
||||
self.bytes = self.bytes + #res.shinyBytes
|
||||
self.shiny = (self.shiny or 0) + 1
|
||||
end
|
||||
self.species = res.species
|
||||
else
|
||||
self.failed[#self.failed + 1] = fileno
|
||||
|
||||
+64
-11
@@ -61,7 +61,14 @@ StadiumInstall.FORMAT = "DSM3"
|
||||
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
|
||||
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
|
||||
-- any rev-1 build.
|
||||
StadiumInstall.REV = 2
|
||||
--
|
||||
-- Rev 3 adds the shiny variants (NNNs.dsm). The normal packs are unchanged
|
||||
-- byte for byte, so this is exactly the case REV exists for and not a FORMAT
|
||||
-- bump: nothing about DSM3 moved, there is simply a second file per species
|
||||
-- that a rev-2 cache does not have. Without the bump a player who already
|
||||
-- installed would keep a complete-looking cache with no shiny models in it,
|
||||
-- and every shiny they met would silently show its normal colours.
|
||||
StadiumInstall.REV = 3
|
||||
|
||||
StadiumInstall.COUNT = 151
|
||||
|
||||
@@ -182,22 +189,46 @@ local function shipped()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Whether the STADIUM rungs can be offered at all: either the packs have been
|
||||
-- built from the player's ROM, or the mod folder already carries a set.
|
||||
-- Whether the packs on disk can be READ, even if they are not current.
|
||||
--
|
||||
-- Format and count, but deliberately NOT rev. The distinction matters on an
|
||||
-- upgrade: a rev bump means the packs are out of date, not that they are
|
||||
-- unreadable, and treating the two the same is what would make the STADIUM
|
||||
-- rungs disappear off the options row for anyone whose cache predates it.
|
||||
-- Losing the recolour until a rebuild is a blemish; losing the mode is not.
|
||||
function StadiumInstall.usable()
|
||||
local m = readMarker()
|
||||
return (m ~= nil and m.format == StadiumInstall.FORMAT
|
||||
and m.count == StadiumInstall.COUNT) and true or false
|
||||
end
|
||||
|
||||
-- Whether the STADIUM rungs can be offered at all: the packs have been built
|
||||
-- from the player's ROM (current or merely readable), or the mod folder
|
||||
-- already carries a set.
|
||||
function StadiumInstall.available()
|
||||
if StadiumInstall.ready() then return true end
|
||||
if StadiumInstall.usable() then return true end
|
||||
return shipped()
|
||||
end
|
||||
|
||||
-- Whether there is work to do: something to build from, and nothing usable
|
||||
-- yet.
|
||||
-- Whether there is work to do: a ROM to build from, and no CURRENT set.
|
||||
--
|
||||
-- A checkout that already carries a set is NOT pending. Building anyway would
|
||||
-- be correct and would also mean a ten-second loading screen on the first run
|
||||
-- of every checkout, to arrive at the files that were already sitting there.
|
||||
-- Keyed on ready() rather than available(), and that is the whole upgrade
|
||||
-- story. It used to short-circuit on available(), which meant a checkout
|
||||
-- carrying assets/stadium was never pending -- so when REV went to 3 for the
|
||||
-- shiny variants, such a machine did not rebuild, was not asked to, and
|
||||
-- quietly kept serving the old set: every shiny Pokemon drawn in its
|
||||
-- ordinary colours, with nothing on screen to say why. That is exactly what
|
||||
-- happened here, and it took a driver run sitting at "idle 0/151" to notice.
|
||||
--
|
||||
-- The cost this trades away is real and was the original reason: a checkout
|
||||
-- with a ROM now spends one loading screen rebuilding a set it already had
|
||||
-- files for. Once. After that ready() is true and it is not pending again --
|
||||
-- and what it buys is that a rev bump actually reaches the people it was
|
||||
-- bumped for.
|
||||
function StadiumInstall.pending()
|
||||
if StadiumInstall.available() then return false end
|
||||
return StadiumInstall.romPresent()
|
||||
if not StadiumInstall.romPresent() then return false end
|
||||
return not StadiumInstall.ready()
|
||||
end
|
||||
|
||||
function StadiumInstall.forget()
|
||||
@@ -211,12 +242,34 @@ local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }
|
||||
|
||||
StadiumInstall.status = status
|
||||
|
||||
local function writePack(species, bytes)
|
||||
-- The shiny variant rides beside its species as NNNs.dsm.
|
||||
--
|
||||
-- A separate FILE rather than a second block inside NNN.dsm, and that is a
|
||||
-- deliberate trade. A second block would mean a new magic (DSM4), the same
|
||||
-- change mirrored into tools/stadium_pack.py, a regenerated oracle and a
|
||||
-- re-run of the 34MB byte diff -- the project's central safety net disturbed
|
||||
-- for a feature that does not need the format to move at all. As its own
|
||||
-- file it is the SAME DSM3 a normal pack is, written by the same writer and
|
||||
-- read by the same reader, and the 151 normal packs stay byte-identical.
|
||||
--
|
||||
-- A species with no shiny variant simply has no NNNs.dsm, and StadiumPack
|
||||
-- falls back to the normal model. That is also what a half-finished install
|
||||
-- looks like, which is the behaviour we want from one.
|
||||
local function writePack(species, bytes, shinyBytes)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species),
|
||||
bytes)
|
||||
if not ok then return false, tostring(err) end
|
||||
if shinyBytes then
|
||||
-- A failed shiny write is not a failed install: the species still has
|
||||
-- its model. Left unwritten, the runtime shows the normal one.
|
||||
local sok, serr = f.write(
|
||||
("%s/%03ds.dsm"):format(StadiumInstall.DIR, species), shinyBytes)
|
||||
if not sok and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny pack %03d not written: %s", species, tostring(serr))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
+71
-3
@@ -179,6 +179,7 @@ function StadiumMon.new(side)
|
||||
return setmetatable({
|
||||
side = side, -- "player" or "enemy"
|
||||
species = nil, -- the dex number currently modelled
|
||||
shiny = false, -- and whether it is the recoloured variant
|
||||
model = nil,
|
||||
rig = nil,
|
||||
state = "idle",
|
||||
@@ -195,6 +196,9 @@ end
|
||||
function StadiumMon:release()
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, nil
|
||||
-- cleared with the species: a stale true here would make the next
|
||||
-- setSpecies believe a shiny model was already loaded and early-return
|
||||
self.shiny = false
|
||||
end
|
||||
|
||||
-- ------- which species this side is showing
|
||||
@@ -222,14 +226,29 @@ end
|
||||
-- DATA rather than a list of dex numbers, so a future extraction bug that
|
||||
-- corrupts a species' idle falls back to the sprite instead of coming
|
||||
-- apart on the field -- and nothing here has to be edited when it does.
|
||||
function StadiumMon:setSpecies(dex)
|
||||
if dex == self.species then return self.rig ~= nil end
|
||||
--
|
||||
-- `shiny` is part of the IDENTITY, not a flag applied afterwards. The early
|
||||
-- return below is keyed on it for that reason: a shiny Rattata and an
|
||||
-- ordinary one share a dex number but are different models, loaded from
|
||||
-- different packs, and comparing on the dex alone would keep whichever
|
||||
-- loaded first and colour both sides with it. That is precisely the shape
|
||||
-- of bug the two-Rattata note above describes, and it is silent -- the
|
||||
-- model is valid, it is simply the wrong one.
|
||||
function StadiumMon:setSpecies(dex, shiny)
|
||||
shiny = shiny and true or false
|
||||
if dex == self.species and shiny == (self.shiny or false) then
|
||||
return self.rig ~= nil
|
||||
end
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, dex
|
||||
self.shiny = shiny
|
||||
self.grow, self.grewOwn = nil, nil
|
||||
if not dex then return false end
|
||||
local model = StadiumPack.load(dex)
|
||||
local model = StadiumPack.load(dex, shiny)
|
||||
if not model then return false end
|
||||
-- the pack falls back to the normal model when a species has no shiny
|
||||
-- variant, so believe the model rather than the request
|
||||
self.shiny = model.shiny and true or false
|
||||
if model.staticPose then return false end
|
||||
local rig = StadiumRig.new(model)
|
||||
if not rig then return false end
|
||||
@@ -456,6 +475,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
|
||||
Mat4.translate(0, -lift, 0))
|
||||
end
|
||||
|
||||
-- How far this Pokemon's LOWEST rendered point stands above the ground it
|
||||
-- is placed on, in world pixels -- the authored hover the matrix above
|
||||
-- gives back, actually applied.
|
||||
--
|
||||
-- Derived by repeating that matrix's own arithmetic rather than by
|
||||
-- re-deriving it in closed form: root scale, the model's floor and the
|
||||
-- hover cap interact in a way that is easy to get subtly wrong, and a
|
||||
-- caller that guessed would place things at the feet of a Pokemon that is
|
||||
-- flying. Which is exactly what a Pidgey does -- it renders a good third
|
||||
-- of its own height clear of its tile, and anything aimed at its cell
|
||||
-- mark lands under it.
|
||||
function StadiumMon:groundGap()
|
||||
local centre, half = self:bodySpan()
|
||||
if centre then return math.max(0, centre - half) end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Where this Pokemon's body actually SITS above the ground it is placed
|
||||
-- on, and how big it is: the centre height, the half height and the
|
||||
-- girth, all in world pixels.
|
||||
--
|
||||
-- Measured off the POSED vertices (StadiumRig:posedBounds) and put
|
||||
-- through this matrix's own scale and lift, so the answer is the shape
|
||||
-- the camera is about to see. That matters most for the species it is
|
||||
-- hardest to guess about: a Pidgey's standby animation flies it well
|
||||
-- clear of its tile, and anything aimed at its cell mark -- a capture
|
||||
-- ring, a thrown ball's collision -- lands under an empty patch of grass
|
||||
-- while the bird hovers above it. Nothing static says so; only the pose
|
||||
-- does.
|
||||
--
|
||||
-- nil before the first skin(), or with no rig: the caller falls back to
|
||||
-- the bind-pose height, which is right for everything that stands.
|
||||
function StadiumMon:bodySpan()
|
||||
local model, rig = self.model, self.rig
|
||||
if not (model and rig and rig.posedBounds) then return nil end
|
||||
local okB, lo, hi, girth = pcall(rig.posedBounds, rig)
|
||||
if not (okB and lo) then return nil end
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local k = root * self:worldHeight() / math.max(model.height, 1e-6)
|
||||
k = k * (self.scale or 1)
|
||||
local floor = model.floor or 0
|
||||
local hover = math.min(math.max(floor, 0),
|
||||
StadiumMon.HOVER_CAP * math.max(model.height, 0))
|
||||
local lift = (floor - hover) / root
|
||||
-- the same map the model matrix applies: world = k * (posed - lift)
|
||||
return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0)
|
||||
end
|
||||
|
||||
-- Pose and skin for this frame. Separate from the draw because both the
|
||||
-- SUN and the camera -- and, in a headset, both eyes -- want the same
|
||||
-- skinned mesh, and skinning it once is the whole reason this is worth
|
||||
|
||||
+88
-22
@@ -62,25 +62,46 @@ local floor = math.floor
|
||||
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
|
||||
StadiumPack.DIR = "assets/stadium"
|
||||
|
||||
local function readPack(species)
|
||||
-- The shiny variant sits beside its species as NNNs.dsm -- the same DSM3,
|
||||
-- written by the same writer, differing only in its texture bytes. See the
|
||||
-- note over StadiumInstall's writePack for why it is a separate file and not
|
||||
-- a second block in the pack.
|
||||
local function packName(dir, species, shiny)
|
||||
return shiny and ("%s/%03ds.dsm"):format(dir, species)
|
||||
or ("%s/%03d.dsm"):format(dir, species)
|
||||
end
|
||||
|
||||
local function readPack(species, shiny)
|
||||
-- The cache only counts when StadiumInstall's marker says it is a
|
||||
-- complete, CURRENT build -- an old cache (a rev the extractor has since
|
||||
-- fixed, a format that moved) must not shadow a fresh shipped set, and a
|
||||
-- half-written folder must not be read at all. Required lazily: Install
|
||||
-- requires this module at load, so the reverse edge cannot be taken then.
|
||||
local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species)
|
||||
local rel = packName(StadiumPack.CACHE_DIR, species, shiny)
|
||||
local install = V.require("StadiumInstall")
|
||||
local mod = V.mod
|
||||
local haveShipped = false
|
||||
if mod and mod.read then
|
||||
local okS, b = pcall(mod.read, mod, packName(StadiumPack.DIR, species, shiny))
|
||||
haveShipped = okS and type(b) == "string" and #b > 4
|
||||
end
|
||||
-- A CURRENT cache always wins. A stale one (readable, but built by an older
|
||||
-- extractor) wins only when there is no shipped set to prefer instead --
|
||||
-- that ordering is what stops a cache from an extractor rev we have since
|
||||
-- fixed shadowing good files, while still leaving something on screen for a
|
||||
-- player whose only copy IS that cache. A half-written folder is caught by
|
||||
-- the marker and satisfies neither.
|
||||
if love and love.filesystem and love.filesystem.getInfo
|
||||
and V.require("StadiumInstall").ready() then
|
||||
and (install.ready() or (install.usable() and not haveShipped)) then
|
||||
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
|
||||
if okInfo and info then
|
||||
local ok, bytes = pcall(love.filesystem.read, rel)
|
||||
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
|
||||
end
|
||||
end
|
||||
local mod = V.mod
|
||||
if not (mod and mod.read) then return nil end
|
||||
local ok, bytes = pcall(mod.read, mod,
|
||||
("%s/%03d.dsm"):format(StadiumPack.DIR, species))
|
||||
packName(StadiumPack.DIR, species, shiny))
|
||||
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
|
||||
return nil
|
||||
end
|
||||
@@ -474,8 +495,27 @@ end
|
||||
|
||||
-- ------- the cache
|
||||
|
||||
local cache = {} -- species -> model
|
||||
local order = {} -- species, least recently used first
|
||||
local cache = {} -- cache key -> model
|
||||
local order = {} -- cache key, least recently used first
|
||||
|
||||
-- The key is the species for a normal model and species+SHINY for a shiny
|
||||
-- one, so the two are separate entries that cannot overwrite each other.
|
||||
--
|
||||
-- They MUST be separate. The model table carries the decoded textures and
|
||||
-- the lazily-built love Images hanging off them, and it is deliberately
|
||||
-- shared by both sides and both VR eyes -- so a single entry per species
|
||||
-- would mean a shiny Rattata and an ordinary one in the same fight fighting
|
||||
-- over one texture set, and whichever loaded last would colour both.
|
||||
local SHINY = 1000 -- clear of the 1..151 dex range
|
||||
|
||||
local function cacheKey(species, shiny)
|
||||
if not species then return nil end
|
||||
return shiny and (species + SHINY) or species
|
||||
end
|
||||
|
||||
-- Four, because a mirror match between a shiny and a normal of the SAME
|
||||
-- species is now two distinct models rather than one shared table, and both
|
||||
-- sides must survive a fifth species being called out mid-fight. See keep().
|
||||
StadiumPack.KEEP = 4
|
||||
|
||||
local function touch(species)
|
||||
@@ -520,30 +560,48 @@ end
|
||||
-- So the mode says, every frame, which two species are actually standing
|
||||
-- there (see Stadium.update). With KEEP at 4 and two sides, the two in use
|
||||
-- are always the two most recent and cannot reach the front of the queue.
|
||||
function StadiumPack.keep(species)
|
||||
if species and cache[species] then touch(species) end
|
||||
function StadiumPack.keep(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then touch(key) end
|
||||
end
|
||||
|
||||
-- Whether a pack for this species is on disk at all. Cheap enough to ask
|
||||
-- before a battle commits to the mode, and the honest test: a mod
|
||||
-- installed without its assets folder must decline rather than error.
|
||||
function StadiumPack.available(species)
|
||||
if cache[species] then return true end
|
||||
return readPack(species) ~= nil
|
||||
--
|
||||
-- Asked WITHOUT the shiny flag on purpose by the callers that gate the mode:
|
||||
-- whether a species can be modelled at all is a question about its normal
|
||||
-- pack. A missing shiny variant does not disqualify the species, it just
|
||||
-- means that one mon is drawn in its ordinary colours.
|
||||
function StadiumPack.available(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then return true end
|
||||
return readPack(species, shiny) ~= nil
|
||||
end
|
||||
|
||||
-- The model for a National Dex number (1..151), or nil.
|
||||
function StadiumPack.load(species)
|
||||
--
|
||||
-- `shiny` selects the recoloured variant. When a species has no shiny pack
|
||||
-- -- an install from before rev 3, a recolour that failed at extraction, a
|
||||
-- species we have no colours for -- this FALLS BACK to the normal model
|
||||
-- rather than returning nil. The alternative is a shiny Pokemon that drops
|
||||
-- to a flat 2D pic while its ordinary twin stands in 3D, which reads as a
|
||||
-- bug; wrong colours read as a mod that has not finished installing.
|
||||
function StadiumPack.load(species, shiny)
|
||||
if not (species and species >= 1 and species <= 151) then return nil end
|
||||
local hit = cache[species]
|
||||
local key = cacheKey(species, shiny)
|
||||
local hit = cache[key]
|
||||
if hit ~= nil then
|
||||
touch(species)
|
||||
touch(key)
|
||||
return hit or nil
|
||||
end
|
||||
|
||||
local bytes = readPack(species)
|
||||
local bytes = readPack(species, shiny)
|
||||
if not bytes and shiny then
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
if not bytes then
|
||||
cache[species] = false
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -562,14 +620,22 @@ function StadiumPack.load(species)
|
||||
return m
|
||||
end)
|
||||
if not ok then
|
||||
V.mod.log:warn("stadium: %03d.dsm did not read: %s -- that Pokemon "
|
||||
.. "falls back to its flat pic", species, tostring(model))
|
||||
cache[species] = false
|
||||
V.mod.log:warn("stadium: %s did not read: %s -- that Pokemon "
|
||||
.. "falls back to its flat pic",
|
||||
packName("", species, shiny):sub(2), tostring(model))
|
||||
-- A corrupt SHINY pack must not cost the species its model: fall back to
|
||||
-- the normal one, exactly as a missing file does above.
|
||||
if shiny then
|
||||
cache[key] = false
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
cache[species] = model
|
||||
touch(species)
|
||||
model.shiny = shiny and true or nil
|
||||
cache[key] = model
|
||||
touch(key)
|
||||
return model
|
||||
end
|
||||
|
||||
|
||||
@@ -728,6 +728,36 @@ function StadiumRig:skin(yaw)
|
||||
end
|
||||
end
|
||||
|
||||
-- What this POSE actually occupies, in the rig's own posed space: the
|
||||
-- vertical span of every skinned vertex, and the furthest any of them
|
||||
-- stands from the model's vertical axis.
|
||||
--
|
||||
-- Read off the skinned rows rather than off the pack's bind-pose figures,
|
||||
-- because the two are not the same claim. The bind measurements say how
|
||||
-- big the model is; a caller placing something ON the Pokemon needs to
|
||||
-- know where the Pokemon IS, and for a flying species the standby
|
||||
-- animation carries it a third of its own height off the floor -- a lift
|
||||
-- that exists only in the posed bones and appears in no static field.
|
||||
--
|
||||
-- Answers nil before the first skin(), which is the caller's cue to fall
|
||||
-- back to the bind figures.
|
||||
function StadiumRig:posedBounds()
|
||||
local lo, hi, r2 = nil, nil, 0
|
||||
for _, part in ipairs(self.parts) do
|
||||
local rows, n = part.rows, part.prim.vertCount
|
||||
for k = 1, n do
|
||||
local row = rows[k]
|
||||
local y = row[2]
|
||||
if not lo or y < lo then lo = y end
|
||||
if not hi or y > hi then hi = y end
|
||||
local d = row[1] * row[1] + row[3] * row[3]
|
||||
if d > r2 then r2 = d end
|
||||
end
|
||||
end
|
||||
if not lo then return nil end
|
||||
return lo, hi, math.sqrt(r2)
|
||||
end
|
||||
|
||||
-- ------- which texture each part wears this frame
|
||||
--
|
||||
-- The eyes. A primitive whose display list carried geo command 0x23 with a
|
||||
|
||||
+355
-11
@@ -227,7 +227,11 @@ function Structures.forMap(map)
|
||||
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
|
||||
hideBareRing = hullRingOnly or nil,
|
||||
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {},
|
||||
-- tile key -> the row a collapsed bookcase rank's box actually
|
||||
-- stands on, so a standee supported by one lands on it rather than
|
||||
-- where the drawing put it (see buildBookcases)
|
||||
bookcaseBox = {} }
|
||||
Buildings.build(S, map, pixels(tileset), perRow)
|
||||
|
||||
-- Fold doors into their buildings. A door cell is WALKABLE (the player
|
||||
@@ -601,9 +605,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 +881,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 +993,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 +1319,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 +1343,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 +1465,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 +1486,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
|
||||
@@ -1854,6 +1886,12 @@ local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV,
|
||||
end
|
||||
end
|
||||
|
||||
-- The arts a `bookcase_backfill = "above"` row may inherit: terrain and
|
||||
-- solid bodies only (see the note at the backfill itself). Everything
|
||||
-- absent here -- billboard, post, cylinder, grass, flower -- is a per-pixel
|
||||
-- object STANDING on terrain rather than terrain.
|
||||
local BACKFILL_ART = { flat = true, top = true, upright = true }
|
||||
|
||||
function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
|
||||
perRow = perRow or map.tileset.tilesPerRow or 16
|
||||
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
|
||||
@@ -1900,11 +1938,32 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
|
||||
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
|
||||
-- the cell above the run instead, shape and art, so a wall cut into
|
||||
-- a terrace has more terrace behind it rather than a trench.
|
||||
--
|
||||
-- Only BODY above backfills: a vacated row wants more of the
|
||||
-- terrace the wall is cut into, and the terrace is whatever lies
|
||||
-- flat, tops out or stands as a solid face. A per-pixel STANDEE
|
||||
-- above -- a statue, a sign, a bush -- is an object standing ON
|
||||
-- that terrace, and copying it northward builds a second and a
|
||||
-- third of it: Indigo Plateau's avenue statues sit directly on
|
||||
-- the pilasters that collapse here, so every bird came out
|
||||
-- duplicated twice down the shaft behind itself. A standee
|
||||
-- above means the row has no terrace to inherit, so it takes the
|
||||
-- default and is painted with synthesized ground.
|
||||
local covered = math.min(2, front - top + 1)
|
||||
local srcK = keyOf(tx, top - 1)
|
||||
local src = backfill == "above" and S.shapeAt[srcK] or nil
|
||||
if src and not BACKFILL_ART[src.art] then src = nil end
|
||||
-- Where the box ACTUALLY ends up, remembered for every row of the
|
||||
-- rank: the collapse walks the whole drawn run onto its southmost
|
||||
-- cell, so anything that has to stand ON the box has to be told
|
||||
-- where the box went. A statue keys off the cell below its own
|
||||
-- drawing, which is the run's NORTH end -- two rows away from the
|
||||
-- box on a two-cell pilaster, which is exactly the distance the
|
||||
-- Plateau's birds floated by.
|
||||
local boxTop = front - covered + 1
|
||||
for cy = top, front do
|
||||
local tk = keyOf(tx, cy)
|
||||
S.bookcaseBox[tk] = boxTop
|
||||
if src and cy <= front - covered then
|
||||
S.shapeAt[tk] = src
|
||||
S.tileAt[tk] = S.tileAt[srcK]
|
||||
@@ -1964,6 +2023,10 @@ end
|
||||
-- walls) wear the matching slice of that drawing -- the railing's
|
||||
-- diagonal lands along the stepped silhouette -- while treads sample the
|
||||
-- art band drawn at their own height.
|
||||
--
|
||||
-- stair_n / stair_down_n are the same pair of flights running INTO the
|
||||
-- map rather than across it, for a staircase drawn head-on; that changes
|
||||
-- the art reading enough to need its own branch below.
|
||||
local STAIR_STEPS = 4
|
||||
|
||||
local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0,
|
||||
@@ -1976,7 +2039,9 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
local north = s.class == "stair_n" or s.class == "stair_down_n"
|
||||
local down = s.class == "stair_down_n" or s.class == "stair_down_e"
|
||||
or s.class == "stair_down_w"
|
||||
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
||||
local mx, mz = cx * 16, cy * 16
|
||||
local h = s.h or 16
|
||||
@@ -2025,6 +2090,114 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
end
|
||||
end
|
||||
|
||||
-- A flight running INTO the map instead of across it. The drawing is
|
||||
-- the same staircase seen head-on rather than from the side, and that
|
||||
-- changes which axis of the art means what: a drawn ROW is a step here,
|
||||
-- and -- because looking down a well is looking along its depth -- drawn
|
||||
-- row IS depth row, 1:1 across the cell's 16.
|
||||
--
|
||||
-- The Centers' steps state their own band table and it lands exactly:
|
||||
-- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So
|
||||
-- an even four-step division puts a black NOSING on the southmost row of
|
||||
-- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
|
||||
-- tread. Nothing is authored but the RISE, which no head-on drawing can
|
||||
-- state; the depths, the treads and the nosings are all measured.
|
||||
--
|
||||
-- A nosing is drawn as one row because it is seen nearly edge-on, so
|
||||
-- un-projected it has real height and no depth: its row lies flat as the
|
||||
-- tread's front lip AND stands as the riser under it. That is the one
|
||||
-- texel in the flight used twice, and using it twice is what a nosing is.
|
||||
--
|
||||
-- The well's own walls come free as well: the drawing's first and last
|
||||
-- COLUMNS are its black side walls, and its top band is the darkness the
|
||||
-- flight leaves by, which is what the far end wants to wear.
|
||||
--
|
||||
-- A flight CLIMBING away (`stair_n`) is the same reading with the sign of
|
||||
-- the rise flipped -- bands still run south to north, drawn row is still
|
||||
-- depth row, the nosing still serves twice. Two things follow from the
|
||||
-- sign. The risers turn around: a flight descending away from you closes
|
||||
-- its steps from below and shows you their backs, one climbing away shows
|
||||
-- you their FRONTS, so they face south. And the drawing's black side
|
||||
-- columns stop being a well's walls and become the walls of the opening
|
||||
-- the flight climbs into: they run from each tread UP to the top of the
|
||||
-- wall band rather than down from the floor. At the last step the flight
|
||||
-- has reached that top and there is no opening left to wall.
|
||||
--
|
||||
-- Every quad here is split at the cell's own 8px seam, in x and in rows
|
||||
-- both: `uv` resolves ONE tile per corner, and these four tiles are not
|
||||
-- neighbours in the atlas, so a quad that spans a seam interpolates
|
||||
-- between two unrelated corners of the sheet.
|
||||
if north then
|
||||
local runD = 16 / STAIR_STEPS
|
||||
local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local a0 = 16 - (i + 1) * runD -- band i, in art rows
|
||||
local a1 = a0 + runD
|
||||
local yTop = (down and -1 or 1) * (i + 1) * rise
|
||||
local ry = (down and -1 or 1) * i * rise -- the step behind it
|
||||
local z0b, z1b = mz + a0, mz + a1
|
||||
|
||||
for _, H in ipairs(HALVES) do
|
||||
local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
|
||||
|
||||
-- the tread: the whole band, drawn row = depth row, so the nosing
|
||||
-- lies on its front lip exactly where the artist drew it
|
||||
face({ wx0, yTop, z0b }, { wx1, yTop, z0b },
|
||||
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
ax0, a1, ax1, a0,
|
||||
down and STAIR_SHADE.wellTread or STAIR_SHADE.tread)
|
||||
|
||||
-- the riser at that lip, one art row tall -- so it needs none of
|
||||
-- `banded`'s row splitting, and written straight keeps the geometry
|
||||
-- flush at the seam while the art stays inside its tile. Facing
|
||||
-- north when the flight descends (the steps are closed from below,
|
||||
-- not looked at) and south when it climbs
|
||||
if down then
|
||||
face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
{ wx0, ry, z1b }, { wx1, ry, z1b },
|
||||
ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
|
||||
else
|
||||
face({ wx0, ry, z1b }, { wx1, ry, z1b },
|
||||
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
ax0, a1 - 1, ax1, a1, STAIR_SHADE.riser)
|
||||
end
|
||||
|
||||
-- the deep end, closing the opening this flight is cut into: from
|
||||
-- the floor of the well up to the top of the wall band beside it,
|
||||
-- in the drawing's own black top rows. A climbing flight has no
|
||||
-- such end -- its top tread stands at the wall's own height and
|
||||
-- fills the opening
|
||||
if down and i == STAIR_STEPS - 1 then
|
||||
face({ wx1, -h, mz }, { wx0, -h, mz },
|
||||
{ wx0, h, mz }, { wx1, h, mz },
|
||||
ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd)
|
||||
end
|
||||
end
|
||||
|
||||
-- the opening's side walls beside this tread, wearing the drawing's
|
||||
-- own black edge columns -- excavation or recess, it is walled in its
|
||||
-- own texels. Descending they run from the tread up to the floor,
|
||||
-- climbing from the tread up to the top of the wall band
|
||||
local wallTop = down and 0 or h
|
||||
local function sideWall(px, sx0, sx1, inward)
|
||||
local c
|
||||
if inward then -- west wall, faces E
|
||||
c = { { px, yTop, z1b }, { px, yTop, z0b },
|
||||
{ px, wallTop, z0b }, { px, wallTop, z1b } }
|
||||
else -- east wall, faces W
|
||||
c = { { px, yTop, z0b }, { px, yTop, z1b },
|
||||
{ px, wallTop, z1b }, { px, wallTop, z0b } }
|
||||
end
|
||||
face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
|
||||
end
|
||||
if wallTop > yTop then
|
||||
sideWall(mx, 0.1, 1.3, true)
|
||||
sideWall(mx + 16, 14.7, 15.9, false)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
|
||||
local sx1 = sx0 + runW
|
||||
@@ -2124,6 +2297,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
||||
-- box or floor it. A rising flight stands on the map's common
|
||||
-- floor; a stairwell IS the hole, so nothing is painted under it
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
or s.class == "stair_down_n"
|
||||
for dy = 0, 1 do
|
||||
for dx = 0, 1 do
|
||||
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
||||
@@ -2225,6 +2399,43 @@ function Structures.buildVolume(S, map, tiles)
|
||||
-- whether the region's dominant columns are flat repeats (a cliff
|
||||
-- mound's plateau) rather than drawn facades (a house's front)
|
||||
local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN
|
||||
|
||||
-- Whether this REGION's tops are a rim over a uniform body -- what every
|
||||
-- cliff mound is drawn as: a top edge, then the same rock the whole way
|
||||
-- down. The top face may then lay that rim once along its north edge and
|
||||
-- hold the body after it, instead of cycling the rim back every second
|
||||
-- tile and striping a plateau with edges it should not have.
|
||||
--
|
||||
-- Answered per column AND per region, because each catches what the
|
||||
-- other misses. A mound is one structure many columns wide, and the
|
||||
-- columns carrying its cave mouth read differently from their neighbours
|
||||
-- (their drawing ends in the mouth's own tiles): per column alone, those
|
||||
-- kept cycling while the rest held, leaving rim stubs above the doorway.
|
||||
-- But a region vote alone silences a genuine rim-over-body column that
|
||||
-- happens to stand in a region of repeating art -- three of them in the
|
||||
-- Safari Zone. A column holds if EITHER says so.
|
||||
--
|
||||
-- Art that genuinely repeats is not uniform and keeps cycling: the
|
||||
-- Safari Zone's fence alternates two tiles the whole way down, and there
|
||||
-- the repeat IS what the drawing says.
|
||||
local uniformVotes, uniformTotal = 0, 0
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
if run.extent > 2 then
|
||||
uniformTotal = uniformTotal + 1
|
||||
local body = map:tileAt(r.tx, run.north + 1)
|
||||
local uniform = true
|
||||
for d = 2, run.extent - 1 do
|
||||
if map:tileAt(r.tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
run.ownUniform = uniform
|
||||
if uniform then uniformVotes = uniformVotes + 1 end
|
||||
end
|
||||
end
|
||||
local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
local h = run.unit * 8
|
||||
@@ -2272,6 +2483,7 @@ function Structures.buildVolume(S, map, tiles)
|
||||
run.rise = roofRows * 8
|
||||
run.peak = h
|
||||
run.h = h - run.rise -- facade height: what sides build to
|
||||
run.topUniform = run.ownUniform or regionUniform
|
||||
for ty = run.north, run.front do
|
||||
S.runs[keyOf(r.tx, ty)] = run
|
||||
end
|
||||
@@ -2659,9 +2871,10 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
-- Town is where it showed: pinning the cliff's slope chain gave the
|
||||
-- posts along the cliff edge an authored 16px box to their south, and
|
||||
-- they were hoisted to stand on the clifftop instead of the path.
|
||||
local baseY, support = 0, nil
|
||||
local baseY, support, supportRow = 0, nil, nil
|
||||
if force and force ~= "opaque" then
|
||||
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
|
||||
local belowK = keyOf(cluster.minX, cluster.maxY + 1)
|
||||
local bs = S.shapeAt[belowK]
|
||||
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
|
||||
math.floor(cluster.maxY / 2))
|
||||
-- `bookcase` supports as well as `upright`. A prop drawn above an
|
||||
@@ -2678,6 +2891,13 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
and (bs.art == "upright" or bs.art == "bookcase"
|
||||
or bs.class == "building") then
|
||||
baseY, support = bs.h, bs
|
||||
-- A bookcase support has MOVED: the collapse walks the whole drawn
|
||||
-- run onto its southmost cell, and the cell tested above is the run's
|
||||
-- north end. On the Plateau's two-cell pilasters that is a full cell
|
||||
-- away, and the bird stood at the right HEIGHT over open ground with
|
||||
-- its pillar behind it -- floating. Stand it on the box's own north
|
||||
-- row instead of one row south of its drawing.
|
||||
supportRow = S.bookcaseBox[belowK]
|
||||
end
|
||||
end
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
@@ -2729,8 +2949,9 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
end
|
||||
end
|
||||
for _, c in ipairs(comps) do
|
||||
c.z0 = cluster.minY * 8 + math.floor(c.lowY / 8) * 8
|
||||
+ (support and 8 or 0) + (8 - depth) / 2
|
||||
c.z0 = supportRow and (supportRow * 8 + (8 - depth) / 2)
|
||||
or (cluster.minY * 8 + math.floor(c.lowY / 8) * 8
|
||||
+ (support and 8 or 0) + (8 - depth) / 2)
|
||||
c.z1 = c.z0 + depth
|
||||
end
|
||||
|
||||
@@ -3023,6 +3244,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
|
||||
end
|
||||
end
|
||||
|
||||
-- An AUTHORED solid standing on furniture, given as plan layers instead of
|
||||
-- extruded from the drawing (see TileShape's `model`). The one thing it
|
||||
-- shares with the mask paths is that nothing here is a colour: each layer
|
||||
-- names the atlas texels its top and its sides wear, and every quad below
|
||||
-- samples one of them, so the Centers' bell is painted out of the counter's
|
||||
-- own pixels and recolours with it.
|
||||
--
|
||||
-- Placement is by CELL, not by drawn row. A model exists because the
|
||||
-- drawing was too small to un-project, so its drawn row says nothing about
|
||||
-- depth worth keeping -- what says something is which piece of furniture it
|
||||
-- is on and which end of it a person reaches: the solid is centred on the
|
||||
-- mask's own columns and pushed to the SOUTH edge of the support cell, the
|
||||
-- face the aisle is on, less the entry's `inset` -- the one number here
|
||||
-- taste can move, because flush against the counter's own front lip is a
|
||||
-- real position and so is a couple of voxels back from it.
|
||||
local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0)
|
||||
local function uvOf(t)
|
||||
local tile, row, col = t[1], t[2], t[3] or 0
|
||||
return ((tile % perRow) * 8 + col + 0.5) / atlasW,
|
||||
(math.floor(tile / perRow) * 8 + row + 0.5) / atlasH
|
||||
end
|
||||
|
||||
for k, L in ipairs(m) do
|
||||
local u, v = uvOf(L.side)
|
||||
local ut, vt = uvOf(L.top)
|
||||
local above = m[k + 1]
|
||||
local x0 = xMid - math.floor(L.w / 2)
|
||||
local z0 = zSouth - L.d
|
||||
local function solid(layer, dx, dz)
|
||||
if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then
|
||||
return false
|
||||
end
|
||||
return layer.cells[dz * layer.w + dx] or false
|
||||
end
|
||||
for dz = 0, L.d - 1 do
|
||||
for dx = 0, L.w - 1 do
|
||||
if solid(L, dx, dz) then
|
||||
local x, y, z = x0 + dx, y0 + k - 1, z0 + dz
|
||||
local function quad(c1, c2, c3, c4, uu, vv, shade)
|
||||
quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv,
|
||||
shade = shade }
|
||||
end
|
||||
-- a layer's own plan is what closes it: a face is drawn wherever
|
||||
-- the neighbouring cell of this layer is empty, and the top
|
||||
-- wherever the layer ABOVE does not stand on it. Nothing needs a
|
||||
-- bottom -- layer 1 rests on the furniture and the rest rest on
|
||||
-- each other.
|
||||
if not solid(above, dx, dz) then
|
||||
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
|
||||
{ x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top)
|
||||
end
|
||||
if not solid(L, dx, dz + 1) then
|
||||
quad({ x, y, z + 1 }, { x + 1, y, z + 1 },
|
||||
{ x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v,
|
||||
OBJ_SHADE.front)
|
||||
end
|
||||
if not solid(L, dx, dz - 1) then
|
||||
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
|
||||
{ x + 1, y + 1, z }, u, v, OBJ_SHADE.back)
|
||||
end
|
||||
if not solid(L, dx - 1, dz) then
|
||||
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
|
||||
{ x, y + 1, z }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
if not solid(L, dx + 1, dz) then
|
||||
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
|
||||
{ x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
|
||||
|
||||
-- One authored figure at one matched position.
|
||||
@@ -3105,7 +3400,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
|
||||
if fig.depth then
|
||||
if fig.model then
|
||||
-- An authored solid: centred on the mask's own columns, standing on
|
||||
-- the furniture's top plane at the front of its cell.
|
||||
local maxX = minX
|
||||
for ly = 0, bh - 1 do
|
||||
for lx = 0, bw - 1 do
|
||||
if at(lx, ly) and lx > maxX then maxX = lx end
|
||||
end
|
||||
end
|
||||
local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2)
|
||||
local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0)
|
||||
maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH,
|
||||
xMid, zSouth, baseY)
|
||||
elseif fig.depth then
|
||||
-- An OBJECT: the standee slab, standing on the FRONT edge of the tile
|
||||
-- row its feet are drawn in -- the south face of the 8px band a
|
||||
-- character card would have pivoted in. It is anchored there and
|
||||
@@ -3480,13 +3788,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
|
||||
|
||||
@@ -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
|
||||
@@ -117,6 +125,14 @@ local FALLBACK_HEIGHTS = {
|
||||
stair_w = 16,
|
||||
stair_down_e = 16,
|
||||
stair_down_w = 16,
|
||||
-- a flight running toward the BACK of the map, drawn head-on instead of
|
||||
-- from the side (the Centers' Cable Club steps). Its own class because
|
||||
-- the art reading is not the east/west one turned: there a drawn COLUMN
|
||||
-- is a step and a drawn row is height, here a drawn ROW is a step and
|
||||
-- drawn row = depth row, 1:1 into the opening. `stair_n` climbs away
|
||||
-- from the room, `stair_down_n` descends into a well
|
||||
stair_n = 16,
|
||||
stair_down_n = 16,
|
||||
}
|
||||
|
||||
-- class -> how the mesher draws it (see the header). The last three are
|
||||
@@ -147,6 +163,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
|
||||
@@ -206,6 +223,8 @@ local ART = {
|
||||
stair_w = "stair",
|
||||
stair_down_e = "stair",
|
||||
stair_down_w = "stair",
|
||||
stair_n = "stair",
|
||||
stair_down_n = "stair",
|
||||
}
|
||||
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
@@ -460,6 +479,8 @@ end
|
||||
--
|
||||
-- figures = { { w = <tiles across>,
|
||||
-- depth = <voxels of body; ABSENT for a person>,
|
||||
-- model = { ...authored plan layers, bottom first... },
|
||||
-- inset = <voxels back from the support cell's front>,
|
||||
-- thin = { rows = <top rows>, depth = <voxels> },
|
||||
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
@@ -479,6 +500,16 @@ end
|
||||
-- same furniture the card would have stood on. The Marts' cash
|
||||
-- register is the case: a machine on a counter is a box, not an icon.
|
||||
--
|
||||
-- `model` is the third answer, and the only one that is not an extrusion
|
||||
-- of the drawing at all: an AUTHORED solid, given as plan layers bottom
|
||||
-- first, standing at the FRONT of the support cell. It exists for a
|
||||
-- drawing too small to un-project -- the Centers' push bell is 7x6 pixels
|
||||
-- of ¾-view dome, and no reading of six rows produces a shape a mask can
|
||||
-- extrude without inventing more than it measures. What it still may not
|
||||
-- invent is COLOUR: each layer names the atlas texel its top and its
|
||||
-- sides wear, so the solid is painted out of the drawing it replaces and
|
||||
-- follows every palette bake exactly like the rest of this file.
|
||||
--
|
||||
-- Two fields say which parts of such a drawing are NOT the extrusion,
|
||||
-- because a solid drawn in one 16x16 GB cell still packs more than one
|
||||
-- facing:
|
||||
@@ -548,10 +579,43 @@ local function authoredMasks(list)
|
||||
r0 = math.floor(f.flat.rows[1]),
|
||||
r1 = math.floor(f.flat.rows[2]) }
|
||||
end
|
||||
-- an AUTHORED model: plan layers bottom-first, each with the atlas
|
||||
-- texel its top and its sides wear. Dropped whole on any malformed
|
||||
-- layer, like every other field here -- a typo should leave the
|
||||
-- drawing lying flat, not build half a solid.
|
||||
local model = nil
|
||||
if type(f.model) == "table" and #f.model > 0 then
|
||||
model = {}
|
||||
for _, L in ipairs(f.model) do
|
||||
local plan = type(L) == "table" and L.plan
|
||||
local mw = (type(plan) == "table" and type(plan[1]) == "string")
|
||||
and #plan[1] or 0
|
||||
local okL = mw > 0 and type(L.top) == "table"
|
||||
and type(L.side) == "table"
|
||||
if okL then
|
||||
for _, r in ipairs(plan) do
|
||||
if type(r) ~= "string" or #r ~= mw then okL = false break end
|
||||
end
|
||||
end
|
||||
if not okL then model = nil break end
|
||||
local cells = {}
|
||||
for dz = 0, #plan - 1 do
|
||||
local r = plan[dz + 1]
|
||||
for dx = 0, mw - 1 do
|
||||
if r:sub(dx + 1, dx + 1) ~= "0" then cells[dz * mw + dx] = true end
|
||||
end
|
||||
end
|
||||
model[#model + 1] = { w = mw, d = #plan, cells = cells,
|
||||
top = L.top, side = L.side }
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under,
|
||||
depth = depth and math.floor(depth) or nil,
|
||||
model = model,
|
||||
inset = model and math.floor(tonumber(f.inset) or 0)
|
||||
or nil,
|
||||
thin = thin, flat = flat }
|
||||
end
|
||||
end
|
||||
@@ -700,6 +764,50 @@ function TileShape.bookcaseRelief(tilesetId)
|
||||
return not (entry and entry.bookcase_relief == false)
|
||||
end
|
||||
|
||||
--- What a `wall` cell's TOP face wears in this tileset (a tileset entry's
|
||||
--- wall_top). Returns a function tile -> cap tile id (nil for "leave it
|
||||
--- alone"), or nil when the tileset says nothing at all.
|
||||
---
|
||||
--- A wall band is 16px of art folded upright over a run two drawn rows
|
||||
--- deep, so it folds ENTIRELY onto its face and has no row left to lay
|
||||
--- flat on top -- the top then repeats the face, and a house's town-map
|
||||
--- poster and window came out lying across the top of the wall as well as
|
||||
--- hanging on it. What is up there is the wall's capping course, which is
|
||||
--- the plain panel the decorated column's own neighbours draw; naming it
|
||||
--- is the whole fix, because "plain" is a fact about the drawing that
|
||||
--- nothing in the geometry can measure.
|
||||
---
|
||||
--- Two forms, because tilesets differ in how far one answer reaches:
|
||||
---
|
||||
--- wall_top = <id> EVERY wall cell caps with this course.
|
||||
--- Right where one atlas dresses one kind of
|
||||
--- room -- the town house, the Centers, Red's
|
||||
--- two floors all cap with their own blank
|
||||
--- panel, and a list keyed by the decorated
|
||||
--- tiles would need extending every time a
|
||||
--- map hung something new on the same wall.
|
||||
--- wall_top = { [tile] = id } only these tiles are redirected. Right
|
||||
--- where one atlas dresses several rooms:
|
||||
--- LOBBY is the department store, the Game
|
||||
--- Corner, Silph's floors, the roof AND the
|
||||
--- Rocket lift, and the lift's cabin frame is
|
||||
--- not what a shop wall caps with.
|
||||
function TileShape.wallTop(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local spec = entry and entry.wall_top
|
||||
if type(spec) == "number" then
|
||||
return function() return spec end
|
||||
end
|
||||
if type(spec) == "table" then
|
||||
return function(tile)
|
||||
local cap = spec[tile]
|
||||
return type(cap) == "number" and cap or nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
|
||||
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||
function TileShape.invalidate()
|
||||
|
||||
+219
-18
@@ -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
@@ -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
@@ -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
|
||||
+291
-24
@@ -48,6 +48,21 @@ Voxel3D.FORMAT = {
|
||||
{ "VertexShade", "float", 1 },
|
||||
}
|
||||
|
||||
-- Tall grass carries one extra value: a stable phase shared by every
|
||||
-- vertex in a tuft. Keeping it constant prevents the two ends of a blade
|
||||
-- from shearing apart while the gust travels across the map.
|
||||
Voxel3D.GRASS_FORMAT = {
|
||||
{ "VertexPosition", "float", 3 },
|
||||
{ "VertexTexCoord", "float", 2 },
|
||||
{ "VertexShade", "float", 1 },
|
||||
{ "VertexGrass", "float", 4 }, -- phase, clump centre x/z, effect kind
|
||||
}
|
||||
|
||||
Voxel3D.GRASS_WIND_PIXELS = 1.15
|
||||
Voxel3D.GRASS_WIND_SPEED = 2.35
|
||||
Voxel3D.GRASS_INTERACT_RADIUS = 12
|
||||
Voxel3D.GRASS_INTERACT_PIXELS = 2.5
|
||||
|
||||
-- Face shading by direction id: top faces stay
|
||||
-- full brightness, sides step down so an extruded block reads as solid
|
||||
-- instead of a flat sticker, and the faces turned away from the sun are
|
||||
@@ -73,6 +88,15 @@ 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
|
||||
@@ -89,9 +113,14 @@ local SHADER = [[
|
||||
uniform float pull;
|
||||
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
|
||||
uniform vec4 fogInfo; // density, start, heightK; density 0 = clear
|
||||
uniform vec4 grassWind; // enabled, time, wind pixels, speed
|
||||
uniform vec4 grassPlayer; // world x, world z, radius, push pixels
|
||||
uniform vec2 grassPrevious; // previous player world xz for swept contact
|
||||
attribute float VertexShade;
|
||||
attribute vec4 VertexGrass;
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
vShade = VertexShade;
|
||||
vFirefly = 0.0;
|
||||
#ifdef VOXEL_GRID
|
||||
// MODEL space, deliberately: every mesh here is built a unit per
|
||||
// voxel in its own frame, so the seams ride the model however it is
|
||||
@@ -110,6 +139,60 @@ local SHADER = [[
|
||||
// answered. (The pull below is excluded for the same reason: it is a
|
||||
// depth trick aimed at the camera's own buffer.)
|
||||
vSun = (sunVP * (sunModel * vertex_position)).xyz;
|
||||
|
||||
// Wind and player contact are applied in world space, before the curved
|
||||
// world and camera pull. vertex y is 0..8 for these tuft meshes, which
|
||||
// pins the root and lets the tip receive the full displacement.
|
||||
if (grassWind.x > 0.5) {
|
||||
float bend = clamp(vertex_position.y / 8.0, 0.0, 1.0);
|
||||
bend *= bend;
|
||||
float wave = sin(grassWind.y * grassWind.w + VertexGrass.x);
|
||||
if (VertexGrass.w > 1.5) {
|
||||
// One-pixel firefly with a full behaviour loop: a long rest on the
|
||||
// grass, smooth take-off, an irregular short flight, descent, landing
|
||||
// and another pause. The baked phase keeps every insect independent.
|
||||
float cycle = fract(grassWind.y * 0.052 + VertexGrass.x * 0.173);
|
||||
float takeoff = smoothstep(0.30, 0.39, cycle);
|
||||
float landing = 1.0 - smoothstep(0.68, 0.79, cycle);
|
||||
float airborne = takeoff * landing;
|
||||
float drift = grassWind.y * 0.83 + VertexGrass.x * 3.7;
|
||||
float wander = sin(drift) * 2.8 + sin(drift * 0.37 + 1.3) * 1.5;
|
||||
float lift = 2.5 + sin(drift * 1.19) * 1.1
|
||||
+ sin(drift * 0.53 + 0.8) * 0.7;
|
||||
w.x += airborne * wander;
|
||||
w.y += airborne * lift;
|
||||
// Mostly dim while resting, visibly brighter in flight, with a soft
|
||||
// asynchronous pulse rather than a hard on/off blink.
|
||||
float blink = 0.68 + 0.32 * (sin(drift * 2.11) * 0.5 + 0.5);
|
||||
vFirefly = fireflyNight * blink * mix(0.14, 0.92, airborne);
|
||||
} else if (VertexGrass.w > 0.5) {
|
||||
// The first 72% of the cycle is airborne. A leaf gets an initial
|
||||
// upward lift, travels with the wind, then gravity accelerates it
|
||||
// down to the ground. It rests there for the remainder before a new
|
||||
// leaf is emitted. Z remains fixed, preserving sprite depth order.
|
||||
float life = fract(grassWind.y * 0.085 + VertexGrass.x * 0.159);
|
||||
float fall = min(life / 0.72, 1.0);
|
||||
float travel = fall * 44.0 - 6.0;
|
||||
float flutter = grassWind.y * 3.0 + VertexGrass.x * 4.7;
|
||||
float lift = sin(fall * 3.14159265) * 5.0;
|
||||
float gravity = 9.0 * fall * fall;
|
||||
float flutterFade = 1.0 - smoothstep(0.62, 1.0, fall);
|
||||
w.x += travel + sin(flutter) * 1.2 * flutterFade;
|
||||
w.y += lift - gravity
|
||||
+ sin(flutter * 0.61) * 0.8 * flutterFade;
|
||||
} else {
|
||||
vec2 offset = vec2(wave * grassWind.z, 0.0);
|
||||
vec2 clump = VertexGrass.yz;
|
||||
float bodyDist = length(clump - grassPlayer.xy);
|
||||
float touch = 1.0 - smoothstep(grassPlayer.z * 0.45,
|
||||
grassPlayer.z, bodyDist);
|
||||
float side = clump.x < grassPlayer.x ? -1.0 : 1.0;
|
||||
if (abs(clump.x - grassPlayer.x) < 0.5)
|
||||
side = sin(VertexGrass.x) < 0.0 ? -1.0 : 1.0;
|
||||
w.xz += offset * bend;
|
||||
w.x += side * touch * grassPlayer.w;
|
||||
}
|
||||
}
|
||||
// THE MAP'S HAZE (see ForestAtmos): how much fog stands between the
|
||||
// eye and this vertex -- distance dissolves into it, altitude climbs
|
||||
// out of it. Worked out on the FLAT world like the shadow lookup
|
||||
@@ -123,6 +206,19 @@ local SHADER = [[
|
||||
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
|
||||
@@ -140,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;
|
||||
@@ -234,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;
|
||||
@@ -284,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
|
||||
@@ -326,12 +503,22 @@ local active = false
|
||||
-- which is exactly the old behaviour minus the reflections.
|
||||
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
|
||||
|
||||
-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more:
|
||||
-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound
|
||||
-- together must agree on PIXEL dimensions. The colour canvas beside this one
|
||||
-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1
|
||||
-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this
|
||||
-- one came back 2.625x larger and the pair would not bind. beginScene then
|
||||
-- dropped the readable depth for the session (see below), depthReadable()
|
||||
-- went false, and the water pass never ran at all: the reflections were
|
||||
-- missing on every high-density display, with nothing in the log to say so,
|
||||
-- because a canvas that will not BIND is not a canvas the driver refused.
|
||||
local function newDepth(w, h)
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local c = nil
|
||||
for _, format in ipairs(DEPTH_FORMATS) do
|
||||
local ok, made = pcall(love.graphics.newCanvas, w, h,
|
||||
{ format = format, readable = true })
|
||||
{ format = format, readable = true, dpiscale = 1 })
|
||||
if ok and made then c = made break end
|
||||
end
|
||||
if not c then return nil end
|
||||
@@ -377,21 +564,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
|
||||
@@ -417,6 +607,15 @@ function Voxel3D.newMesh(verts, map)
|
||||
return mesh
|
||||
end
|
||||
|
||||
function Voxel3D.newGrassMesh(verts, map)
|
||||
if #verts == 0 then return nil end
|
||||
local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.GRASS_FORMAT, verts,
|
||||
"triangles", "static")
|
||||
if not ok then return nil end
|
||||
if map and #map > 0 then pcall(mesh.setVertexMap, mesh, map) end
|
||||
return mesh
|
||||
end
|
||||
|
||||
-- The quad corner offsets and UV corners for one face direction, in the
|
||||
-- order the vertex map below stitches into two triangles. Corners are unit
|
||||
-- offsets from the voxel's (x, y, z) minimum corner.
|
||||
@@ -716,6 +915,22 @@ Voxel3D.tint = { 1, 1, 1 }
|
||||
-- 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
|
||||
@@ -839,12 +1054,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"
|
||||
@@ -893,10 +1116,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
|
||||
@@ -951,12 +1182,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
|
||||
@@ -971,6 +1213,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0)
|
||||
-- on until a sprite pass says otherwise, reset per frame like `ghost`
|
||||
pcall(sh.send, sh, "glassOn", 1)
|
||||
-- still until the dedicated grass pass enables it
|
||||
pcall(sh.send, sh, "grassWind", { 0, 0, 0, 0 })
|
||||
pcall(sh.send, sh, "grassPlayer", { -100000, -100000, 1, 0 })
|
||||
pcall(sh.send, sh, "grassPrevious", { -100000, -100000 })
|
||||
-- the curved world bends about the camera's focus, so the horizon keeps
|
||||
-- a fixed distance ahead of the player rather than sitting on the map.
|
||||
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
|
||||
@@ -1108,7 +1354,10 @@ end
|
||||
function Voxel3D.beginWater(paint)
|
||||
if not (active and canvas and held and held.depth) then return nil end
|
||||
if not held.mirror then
|
||||
local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
|
||||
-- through PixelCanvas, because this one is bound WITH held.depth a few
|
||||
-- lines down and the two must agree on pixel dimensions -- the same
|
||||
-- scale trap newDepth documents
|
||||
local ok, c = PixelCanvas.new(held.w, held.h)
|
||||
if not (ok and c) then return nil end
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
pcall(c.setWrap, c, "clamp", "clamp")
|
||||
@@ -1250,6 +1499,24 @@ function Voxel3D.glass(on)
|
||||
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
|
||||
end
|
||||
|
||||
-- Enable wind only around tall-grass draws. px/pz are the current player's
|
||||
-- feet in world pixels; previous values make collision continuous per frame.
|
||||
function Voxel3D.grassWind(on, px, pz, previousX, previousZ)
|
||||
if not (active and activeShader) then return end
|
||||
if not on then
|
||||
pcall(activeShader.send, activeShader, "grassWind", { 0, 0, 0, 0 })
|
||||
return
|
||||
end
|
||||
local now = love.timer and love.timer.getTime and love.timer.getTime() or 0
|
||||
pcall(activeShader.send, activeShader, "grassWind",
|
||||
{ 1, now, Voxel3D.GRASS_WIND_PIXELS, Voxel3D.GRASS_WIND_SPEED })
|
||||
pcall(activeShader.send, activeShader, "grassPlayer",
|
||||
{ px or -100000, pz or -100000,
|
||||
Voxel3D.GRASS_INTERACT_RADIUS, Voxel3D.GRASS_INTERACT_PIXELS })
|
||||
pcall(activeShader.send, activeShader, "grassPrevious",
|
||||
{ previousX or px or -100000, previousZ or pz or -100000 })
|
||||
end
|
||||
|
||||
function Voxel3D.endGhost()
|
||||
if not active then return end
|
||||
pcall(love.graphics.setDepthMode, "lequal", true)
|
||||
|
||||
+6
-12
@@ -61,19 +61,13 @@ end
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- A pass that needs the wireframe whatever the player left the row on sets
|
||||
-- this for the length of its own draw and puts it back after. nil means
|
||||
-- "follow the setting", which is every frame outside such a pass.
|
||||
--
|
||||
-- The overworld battle is the one user: a fight is a STAGED shot, not the
|
||||
-- world being walked around in, and the seams are what make it read as
|
||||
-- constructed rather than as a photograph of somewhere. The row still owns
|
||||
-- what free-roam looks like, and is not written to -- switching the mode off
|
||||
-- mid-battle would silently rewrite the player's own setting.
|
||||
VoxelGrid.override = nil
|
||||
|
||||
-- The row is the whole answer, everywhere: free-roam and the battle arena
|
||||
-- alike. The battle used to force the seams on regardless -- a fight is a
|
||||
-- STAGED shot, and the seams are what make it read as constructed rather
|
||||
-- than photographed -- but a player who turns the wireframe off means the
|
||||
-- whole mod, and a mode that came back for every fight read as the row not
|
||||
-- working rather than as a deliberate framing.
|
||||
function VoxelGrid.enabled()
|
||||
if VoxelGrid.override ~= nil then return VoxelGrid.override end
|
||||
return VoxelGrid.setting:get() and true or false
|
||||
end
|
||||
|
||||
|
||||
+117
-26
@@ -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
|
||||
@@ -912,6 +935,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
local GlassMask = V.require("GlassMask")
|
||||
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
|
||||
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
||||
-- The existing day/night ramp is also a darkness factor. Fireflies fade
|
||||
-- in naturally at dusk and reach full contrast only at deepest night.
|
||||
Voxel3D.fireflyNight = outdoor and DayNight.windowLight() or 0
|
||||
local g = VoxelScene.glintStep(glint, cx, cy)
|
||||
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
|
||||
-- and the map's atmosphere, if it has one (see ForestAtmos): the haze
|
||||
@@ -920,6 +946,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
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))
|
||||
@@ -951,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.
|
||||
@@ -981,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
|
||||
@@ -993,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,
|
||||
@@ -1015,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
|
||||
@@ -1132,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
|
||||
@@ -1153,9 +1234,11 @@ 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
|
||||
@@ -1199,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
|
||||
@@ -1219,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
|
||||
|
||||
+39
-1
@@ -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.
|
||||
@@ -1089,7 +1111,14 @@ vec4 effect(EFFECT_PREC vec4 color, Image tex, EFFECT_PREC vec2 tc,
|
||||
#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
|
||||
]]
|
||||
@@ -1246,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
@@ -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
|
||||
|
||||
@@ -85,17 +85,27 @@ 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 ShinyPics = V.require("ShinyPics")
|
||||
local ShinyFlash = V.require("ShinyFlash")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
local ForestAtmos = V.require("ForestAtmos")
|
||||
local Shadows = V.require("Shadows")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
local CamControl = V.require("CamControl")
|
||||
local VR = V.require("VR")
|
||||
-- the mod's settings menus: the categories, the screens they open, and the
|
||||
-- red ink that marks this mod's one row on the engine's OPTIONS list
|
||||
local SettingsMenu = V.require("SettingsMenu")
|
||||
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
|
||||
-- every hook; the other four are the gun, the crowd, the readout and the
|
||||
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
|
||||
@@ -103,6 +113,11 @@ local Horde = V.require("Horde")
|
||||
local HordeGun = V.require("HordeGun")
|
||||
local HordeHud = V.require("HordeHud")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the
|
||||
-- wraps and the experience math; CatchThrow the session (input, arc,
|
||||
-- ring, choreography); Pokeball the animated prop they throw.
|
||||
local LetsGo = V.require("LetsGo")
|
||||
local Pokeball = V.require("Pokeball")
|
||||
|
||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||
-- calls this, and it is defined further down with the settings it drives.
|
||||
@@ -195,6 +210,19 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- the atmosphere's own clock (shaft shimmer, drifting motes), on the
|
||||
-- same tick so the beams keep breathing through a dialog box
|
||||
ForestAtmos.update(dt)
|
||||
-- LET'S GO rides the same always-running tick, and BEFORE the battle's
|
||||
-- own update on purpose: the capture session poses the Poke Ball here,
|
||||
-- and OverworldBattle.update renders the arena a moment later -- so
|
||||
-- the ball each frame draws is the ball that frame computed. Guarded,
|
||||
-- and loudly: a fault in the capture game must cost the capture game,
|
||||
-- not the whole voxel pipeline.
|
||||
do
|
||||
local okLG, errLG = pcall(LetsGo.update, dt)
|
||||
if not okLG and not V.letsGoWarned then
|
||||
V.letsGoWarned = true
|
||||
mod.log:warn("LET'S GO update failed: %s", tostring(errLG))
|
||||
end
|
||||
end
|
||||
-- The overworld battle rides this hook rather than owning a pipeline of
|
||||
-- its own, because it owns no pass of the FRAME: it draws under a battle
|
||||
-- screen the engine composites, which is not a stage the registry has.
|
||||
@@ -308,6 +336,7 @@ mod.content.render_pipelines:register("voxel", {
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
|
||||
VR.invalidate() -- the mirror, and FBO ids of dead canvases
|
||||
Pokeball.invalidate() -- the ball's meshes and palette texture
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -373,6 +402,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
|
||||
@@ -423,27 +457,60 @@ local function stagedBattles()
|
||||
return OverworldBattle.enabled()
|
||||
end
|
||||
|
||||
-- ------- this mod's settings, grouped the way the menus present them
|
||||
--
|
||||
-- One entry per setting: the ModSetting itself, the help text the mod
|
||||
-- manager's page carries, and the fields that decide where it is offered.
|
||||
--
|
||||
-- cat which of SettingsMenu's categories the row lives on. The table is
|
||||
-- kept in category order as well, so the mod manager's own page --
|
||||
-- which has no categories to give and lists every row flat -- at
|
||||
-- least keeps related settings next to each other.
|
||||
-- when a predicate. The row is off the menu entirely while it answers
|
||||
-- false, because a row that decides nothing reads as a broken mod.
|
||||
-- full the row SURVIVES the FULL preset. FULL owns the look, so a row
|
||||
-- goes with it by default; `full` marks the ones that were never
|
||||
-- about the look. SettingsMenu leans on this and needs no rule of
|
||||
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
|
||||
-- category empties out under FULL and takes itself off the menu.
|
||||
local SETTINGS = {
|
||||
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
||||
-- ------- the top-level menu -- settings that are about the GAME
|
||||
--
|
||||
-- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
|
||||
-- itself rather than inside one of the four categories, which is right
|
||||
-- here: the categories are the diorama, the fights, what the look costs
|
||||
-- and the headset, and how often a shiny appears is none of those.
|
||||
--
|
||||
-- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
|
||||
-- an encounter rate is a rule of the game. A player inside FULL must be
|
||||
-- able to reach it, and FULL must never set it.
|
||||
{ Shiny.setting,
|
||||
"How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
|
||||
.. "rate, and every rung below it is twice as often as the one above.",
|
||||
cat = SettingsMenu.ROOT, full = true },
|
||||
|
||||
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
|
||||
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
|
||||
cat = "world" },
|
||||
{ WorldCurve.setting,
|
||||
"Bend the world down over the horizon, Animal Crossing style." },
|
||||
"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." },
|
||||
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
|
||||
-- supersampling that is a question about the hardware, not the look.
|
||||
{ ForestAtmos.setting,
|
||||
"The air of the deep woods (Viridian Forest): a ground haze, and "
|
||||
.. "volumetric light let down through the unseen canopy overhead -- "
|
||||
.. "gold spears of sun by day, silver moon rays at night, pollen "
|
||||
.. "drifting through the beams and fireflies once they cool. LOW "
|
||||
.. "keeps the haze, halves the beam march and stands the particles "
|
||||
.. "down. On a phone the row offers LOW alone: the beams need a "
|
||||
.. "depth texture the pass can read back, and no mobile driver here "
|
||||
.. "grants one.",
|
||||
full = true },
|
||||
"Reflections on water: SKY is the sun, moon and sky alone, and FULL "
|
||||
.. "adds the shoreline and trees behind it.",
|
||||
cat = "world" },
|
||||
{ DayNight.setting,
|
||||
"What time it is outdoors -- pinned to an hour, running on a ten-minute "
|
||||
.. "cycle, or synced to the clock on your wall.",
|
||||
cat = "world" },
|
||||
|
||||
-- ------- BATTLES -- what a fight is drawn over, and how it is played
|
||||
--
|
||||
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
|
||||
-- knobs; what a battle is drawn over, and how it is framed, are not that.
|
||||
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
|
||||
@@ -451,57 +518,61 @@ local SETTINGS = {
|
||||
-- and forbids back sprites (backPinned answers false), so both rows
|
||||
-- decide nothing there and a dead switch on the menu reads as broken.
|
||||
{ OverworldBattle.setting,
|
||||
"Fight in three dimensions, shot over the shoulder with a slow parallax "
|
||||
.. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM "
|
||||
.. "replaces them with the Pokemon Stadium battle models, animated, "
|
||||
.. "playing the animation the move being used actually calls for. A "
|
||||
.. "stages the fight on the MAP -- the nearest clear ground, in that "
|
||||
.. "place's own weather and light; B stands it on two discs against the "
|
||||
.. "sky instead, which works everywhere, including the caves and shop "
|
||||
.. "floors that have nowhere to stage a fight. The STADIUM rungs only "
|
||||
.. "appear once the models have been built, and building them needs a "
|
||||
.. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
|
||||
.. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
|
||||
.. "other version works: the reader is keyed to that one cartridge.",
|
||||
"Fights staged in 3D over your shoulder, on the map or on discs against "
|
||||
.. "the sky, as cards or Stadium's animated models.",
|
||||
cat = "battles",
|
||||
when = function() return not VR.enabled() end, full = true },
|
||||
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
|
||||
-- off the engine draws the classic screen, which is this row's ON already,
|
||||
-- and a row that no longer decides anything is worse than no row.
|
||||
{ OverworldBattle.backSetting,
|
||||
"Keep your own Pokemon on the battle menu, seen from behind in its "
|
||||
.. "original slot, instead of standing it on the map facing the foe. "
|
||||
.. "The foe is still out there on its own tile.",
|
||||
"Keeps your own Pokemon on the battle menu, seen from behind, instead "
|
||||
.. "of standing it on the map facing the foe.",
|
||||
cat = "battles",
|
||||
when = function() return stagedBattles() and not VR.enabled() end,
|
||||
full = true },
|
||||
{ DayNight.setting,
|
||||
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
|
||||
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
|
||||
.. "shadows, the sky and the light following -- or SYNC it to the "
|
||||
.. "clock on the wall, so Kanto's evening falls when yours does." },
|
||||
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
|
||||
-- the diorama, so the FULL preset neither sets it nor takes it away.
|
||||
{ LetsGo.setting,
|
||||
"Pokemon GO-style catching -- flick to throw the ball, with FULL adding "
|
||||
.. "half-price balls and party experience (needs 3D-BTL).",
|
||||
cat = "battles", full = true },
|
||||
|
||||
-- ------- PERFORMANCE -- what the look COSTS, which is a different question
|
||||
--
|
||||
-- All three are `full`, and all three for the same reason: FULL is a preset
|
||||
-- for the diorama, not a licence to spend whatever the machine it happens
|
||||
-- to be running on has got. The player decides what their hardware can
|
||||
-- carry, from inside FULL like anywhere else.
|
||||
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
|
||||
-- supersampling that is a question about the hardware, not the look.
|
||||
{ ForestAtmos.setting,
|
||||
"Haze and volumetric light shafts in the deep woods, with pollen in the "
|
||||
.. "beams by day and fireflies at night.",
|
||||
cat = "perf", full = true },
|
||||
-- `full` on AA's reasoning below, and for the same reason: the sun's pass
|
||||
-- is the most expensive thing in the frame after the geometry, so this is
|
||||
-- a question about the machine rather than a knob on the diorama, and it
|
||||
-- has to stay reachable from inside FULL -- which never sets it either.
|
||||
{ Shadows.setting,
|
||||
"Real cast shadows from the sun, and the first thing to switch off on a "
|
||||
.. "phone or an old machine.",
|
||||
cat = "perf", full = true },
|
||||
-- Marked `full` for the opposite reason the battle rows are: this is not a
|
||||
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
|
||||
-- the diorama, not a licence to spend four times the fill rate on the
|
||||
-- machine it happens to be running on, so it neither sets this nor takes
|
||||
-- the row away -- the player decides what their hardware can carry, from
|
||||
-- inside FULL like anywhere else.
|
||||
-- knob on the look at all, it is what the look COSTS.
|
||||
{ AntiAlias.setting,
|
||||
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
|
||||
.. "lips, a tree against the sky -- by rendering the diorama larger than "
|
||||
.. "the window and folding it back down. Every edge in the picture "
|
||||
.. "softens with them, the tileset's own texels included, so the diorama "
|
||||
.. "reads smoother rather than sharper. 2X costs half again as many "
|
||||
.. "pixels in each direction and 4X twice, which makes this the most "
|
||||
"Smooths the stair-stepped edges of the 3D world, and the most "
|
||||
.. "expensive row in the mod.",
|
||||
full = true },
|
||||
cat = "perf", full = true },
|
||||
|
||||
-- ------- VR -- the headset, and the one comfort knob that is only its
|
||||
--
|
||||
-- `full` for the same reason as AA: not a knob on the look, a question
|
||||
-- about the hardware on the desk.
|
||||
{ VR.setting,
|
||||
"PCVR through OpenXR (SteamVR, Oculus, WMR). 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
|
||||
@@ -510,14 +581,17 @@ local SETTINGS = {
|
||||
-- device that is not plugged in decides nothing, and this one is read
|
||||
-- exclusively by the headset's right stick.
|
||||
{ VR.smoothTurn,
|
||||
"Turn smoothly with the right stick instead of snapping 45 degrees a "
|
||||
.. "flick. OFF by default, and deliberately: a software turn moves the "
|
||||
.. "world past a head that did not move, which is the most reliable way "
|
||||
.. "to make somebody ill in a headset. Turn it on if you have your sea "
|
||||
.. "legs and want the continuity.",
|
||||
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
|
||||
@@ -601,10 +675,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")
|
||||
@@ -675,29 +768,31 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the mode's rows, kept together
|
||||
-- ------- the mode's rows, on menus of their own
|
||||
--
|
||||
-- The engine splices a pipeline's row in beside TILT, because a display mode
|
||||
-- belongs with the other display modes; a mod's own ui.options.rows
|
||||
-- additions land at the END of the list. That left this mod's four rows in
|
||||
-- two places with unrelated engine rows between them, which reads as two
|
||||
-- unrelated features rather than one mode with settings.
|
||||
-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
|
||||
-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
|
||||
-- time, so that was four screens of scrolling inside a list that already
|
||||
-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
|
||||
-- there past the wireframe and the horizon bend.
|
||||
--
|
||||
-- So the plain settings are inserted directly after the last of this mod's
|
||||
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
|
||||
-- where the engine already decided display modes go.
|
||||
local function insertGrouped(out, extra)
|
||||
local anchor = nil
|
||||
for i, row in ipairs(out) do
|
||||
local id = type(row) == "table" and row.id
|
||||
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
|
||||
-- Now there is ONE row, and it leads the list. What it opens -- the
|
||||
-- categories, the screens, and why the split falls where it does -- is
|
||||
-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
|
||||
-- display modes, the engine only spliced them beside TILT because it had
|
||||
-- nowhere better, and TILT is not on the menu any more anyway (see below).
|
||||
--
|
||||
-- Two things it takes to move a pipeline row: the engine's descriptor is
|
||||
-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
|
||||
-- through its own step function into save.options.pipelines, and rebuilding
|
||||
-- it here would be a second implementation of something the engine already
|
||||
-- got right -- and the row is then dropped from the top-level list so it is
|
||||
-- not in two places at once.
|
||||
local function captureRow(out, id)
|
||||
for _, row in ipairs(out) do
|
||||
if type(row) == "table" and row.id == id then return row end
|
||||
end
|
||||
if not anchor then
|
||||
for _, row in ipairs(extra) do out[#out + 1] = row end
|
||||
return out
|
||||
end
|
||||
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
|
||||
return out
|
||||
return nil
|
||||
end
|
||||
|
||||
-- FULL owns the settings that describe the LOOK, so while it is selected those
|
||||
@@ -767,6 +862,25 @@ local function pinEngineFx(game)
|
||||
if changed and game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end
|
||||
|
||||
-- ------- the values that follow other values
|
||||
--
|
||||
-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
|
||||
-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
|
||||
-- that row. Both pins used to be a side effect of the rows hook, which every
|
||||
-- step on the OPTIONS menu reran -- so they happened whether or not the step
|
||||
-- was the one that mattered, and nothing had to name them.
|
||||
--
|
||||
-- Now a step can happen on the mod's own menu, where no hook runs, or on the
|
||||
-- mod manager's page, where one never did. So the pinning is a function, and
|
||||
-- all three routes ask for it.
|
||||
local function pinDependents(game)
|
||||
if stagedBattles() then OverworldBattle.forceOG(game) end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
|
||||
end
|
||||
|
||||
SettingsMenu.setOnChanged(pinDependents)
|
||||
|
||||
-- call next() first and decorate what comes back, so every other mod's
|
||||
-- rows survive this one
|
||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
@@ -793,46 +907,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
OverworldBattle.forceOG(game)
|
||||
dropRow(out, "battleLayout")
|
||||
end
|
||||
local full = Voxel.isFull(Pipelines.level("voxel"))
|
||||
if full then
|
||||
if Voxel.isFull(Pipelines.level("voxel")) then
|
||||
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
|
||||
-- horizon bend, the blur, the hour -- so those come off the menu and
|
||||
-- DAYTIME is held at SYNC while its row is unreachable.
|
||||
-- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
|
||||
-- row is unreachable. The rows themselves come off inside SettingsMenu,
|
||||
-- which is where they live now: T-SHIFT with the wireframe and the bend,
|
||||
-- and each of them by the same `full` rule rather than by name.
|
||||
DayNight.forceSync(game)
|
||||
dropRow(out, "pipeline:tiltshift")
|
||||
end
|
||||
local extra = {}
|
||||
for _, entry in ipairs(SETTINGS) do
|
||||
-- Two things decide whether a row is offered.
|
||||
--
|
||||
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
|
||||
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
|
||||
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
|
||||
-- applyFull); it does not hold them, so leaving them on the menu is the
|
||||
-- difference between a preset and a lock.
|
||||
--
|
||||
-- And a row whose own switch is off the table this frame (BACK SPRITES,
|
||||
-- which needs a staged fight to be about) is left off with it. The mod
|
||||
-- manager's page carries every one of them either way.
|
||||
local offered = (entry.full or not full)
|
||||
and (not entry.when or entry.when())
|
||||
if offered then extra[#extra + 1] = entry[1]:row() end
|
||||
-- The two pipeline rows move INTO the mod's own root menu: captured as the
|
||||
-- engine built them, then dropped from here so they are not in two places.
|
||||
local captured, voxelRow = {}, nil
|
||||
for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
|
||||
local row = captureRow(out, id)
|
||||
-- a pipeline the registry refused is simply not there, and the menu says
|
||||
-- so by not offering it rather than by offering a hole
|
||||
if row then captured[#captured + 1] = row end
|
||||
if id == "pipeline:voxel" then voxelRow = row end
|
||||
dropRow(out, id)
|
||||
end
|
||||
-- and the ROM import, which is an ACTION and not a setting: there is no
|
||||
-- rung to store, nothing for the mod manager's page to persist and nothing
|
||||
-- to restore on the next boot, so it is appended here rather than living in
|
||||
-- SETTINGS. nil on a platform with no file dialog, which takes it off the
|
||||
-- menu rather than offering a button that cannot do anything.
|
||||
-- On EVERY platform. Where there is no file dialog it says WHERE? and
|
||||
-- shows the folder to put the cartridge in, which is the one thing a
|
||||
-- player on a phone could not otherwise find out -- the row used to vanish
|
||||
-- there, which reads as the feature being missing rather than manual.
|
||||
local okPick, importRow = pcall(function()
|
||||
return V.require("StadiumRomPick").row()
|
||||
end)
|
||||
if okPick and importRow then extra[#extra + 1] = importRow end
|
||||
return insertGrouped(out, extra)
|
||||
SettingsMenu.setPipelineRows(captured)
|
||||
-- ------- one row, and it leads the list
|
||||
--
|
||||
-- At the TOP rather than spliced in beside the display modes it used to sit
|
||||
-- with. This is a mod that replaces the whole look of the game, and a player
|
||||
-- who installed it and went looking for its settings should not have to
|
||||
-- scroll to find out where they went -- least of all past the engine rows it
|
||||
-- has quietly taken away.
|
||||
--
|
||||
-- Inserted after next() has run, so it leads every OTHER mod's rows too. The
|
||||
-- second line is VOXEL's own value function, which makes the row say what
|
||||
-- the mode is currently doing without opening it -- and reuses the engine's
|
||||
-- label ladder rather than restating it.
|
||||
table.insert(out, 1, {
|
||||
id = SettingsMenu.id(SettingsMenu.ROOT),
|
||||
label = SettingsMenu.ROOT_LABEL,
|
||||
value = voxelRow and voxelRow.value or nil,
|
||||
-- `activate` and not `step`: the engine fires activate on A alone, and a
|
||||
-- row that OPENS something should not also answer Left and Right
|
||||
-- (src/ui/OptionsMenu.update).
|
||||
activate = function(g)
|
||||
g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT))
|
||||
end,
|
||||
})
|
||||
return out
|
||||
end)
|
||||
|
||||
-- The mod manager writes and persists on its own, so the only thing left
|
||||
@@ -843,14 +961,11 @@ mod.events:on("mod.options_changed", function(payload)
|
||||
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
|
||||
end
|
||||
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
|
||||
-- the OPTIONS row does. The manager persists its own value; this is the one
|
||||
-- that has to follow it.
|
||||
if stagedBattles() then OverworldBattle.forceOG() end
|
||||
-- and DAYTIME changed from the manager's page while FULL owns it snaps
|
||||
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
|
||||
-- not, and FULL's pin must hold against both
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
|
||||
-- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
|
||||
-- straight back to SYNC -- that row is off the mod's menus under FULL, but
|
||||
-- the manager's page carries every setting unconditionally, and the pin has
|
||||
-- to hold against both.
|
||||
pinDependents()
|
||||
end)
|
||||
|
||||
-- ------- keeping the geometry in step with the world
|
||||
@@ -950,30 +1065,55 @@ end)
|
||||
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
|
||||
-- clamped rather than reset, so it stays on the row it was just used on
|
||||
-- instead of jumping to the top when the list below it shortens.
|
||||
--
|
||||
-- Held on the INSTANCE rather than compared across one call of update, and
|
||||
-- that is not a tidying: those three rows live in a SUBMENU now, and the
|
||||
-- stack only ticks its top state (src/core/StateStack.update). So the step
|
||||
-- that changes them happens while this menu is suspended and a
|
||||
-- before/after pair taken around inner() would both be read after the fact
|
||||
-- and always agree. A signature that outlives the suspension does not.
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
if not OptionsMenu.dramaticShapeFullHook then
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local inner = OptionsMenu.update
|
||||
local innerPalettes = OptionsMenu.sgbPalettes
|
||||
|
||||
local function idAt(menu, index)
|
||||
local row = menu.rows and menu.rows[index or 1]
|
||||
return type(row) == "table" and row.id or nil
|
||||
end
|
||||
|
||||
-- What the row LIST depends on: whether FULL is selected (it owns the
|
||||
-- rows that describe the look), and the two switches that give and take
|
||||
-- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
|
||||
-- both battle rows while it is on. Only the FULL-ness of the voxel level
|
||||
-- matters, so stepping 35 to 50 is not a change.
|
||||
local function signature()
|
||||
return string.format("%s|%s|%s",
|
||||
tostring(Voxel.isFull(Pipelines.level("voxel"))),
|
||||
tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
|
||||
end
|
||||
|
||||
-- Stamped where the ROWS are built, which is the thing the signature is a
|
||||
-- signature OF. Read lazily on the first update instead and a menu opened
|
||||
-- before the change and updated after it would compare the new state
|
||||
-- against itself and never rebuild.
|
||||
local innerNew = OptionsMenu.new
|
||||
function OptionsMenu.new(game, opts)
|
||||
local menu = innerNew(game, opts)
|
||||
menu.dramaticShapeSig = signature()
|
||||
return menu
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
local before = Pipelines.level("voxel")
|
||||
local hadBattles = OverworldBattle.enabled()
|
||||
-- the VR row hides the two battle rows while it is on, so stepping
|
||||
-- it changes the LIST exactly the way 3D-BTL does
|
||||
local hadVR = VR.enabled()
|
||||
local wasOn = idAt(self, self.index)
|
||||
local before = self.dramaticShapeSig or signature()
|
||||
inner(self, dt)
|
||||
local after = Pipelines.level("voxel")
|
||||
local crossedFull = after ~= before
|
||||
and (Voxel.isFull(before) or Voxel.isFull(after))
|
||||
if crossedFull or OverworldBattle.enabled() ~= hadBattles
|
||||
or VR.enabled() ~= hadVR then
|
||||
local after = signature()
|
||||
self.dramaticShapeSig = after
|
||||
if before ~= after then
|
||||
local rebuilt = OptionsMenu.new(self.game)
|
||||
self.rows = rebuilt.rows
|
||||
-- Follow the row the cursor was ON rather than the slot it was in:
|
||||
@@ -987,6 +1127,36 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- and the mod's own row is red
|
||||
--
|
||||
-- Why this is a palette zone and not love.graphics.setColor -- twice over
|
||||
-- -- is written out in lib/SettingsMenu.lua, next to the code that builds
|
||||
-- the palette. The short of it: setColor picks a SHADE on this screen and
|
||||
-- the zone picks the COLOR.
|
||||
--
|
||||
-- Addressed by SLOT, because the row scrolls: it leads the list, so it is
|
||||
-- normally the top box, but a player who scrolls past it must not leave a
|
||||
-- red band behind on whatever takes its place. Searched by id rather than
|
||||
-- assumed to be row 1 for the same reason -- another mod's hook running
|
||||
-- after ours could put something above it.
|
||||
function OptionsMenu:sgbPalettes(game)
|
||||
local zones = innerPalettes and innerPalettes(self, game) or nil
|
||||
local scroll = self.scroll or 0
|
||||
for slot = 1, OptionRows.VISIBLE do
|
||||
local row = self.rows and self.rows[scroll + slot]
|
||||
if type(row) == "table"
|
||||
and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
|
||||
local zone = SettingsMenu.rowZone(game and game.data, slot)
|
||||
if zone then
|
||||
zones = zones or {}
|
||||
zones[#zones + 1] = zone
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
return zones
|
||||
end
|
||||
|
||||
OptionsMenu.dramaticShapeFullHook = true
|
||||
end
|
||||
end
|
||||
@@ -999,6 +1169,60 @@ end
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- ------- shiny Pokemon
|
||||
--
|
||||
-- ON, always, with no row to switch it off: shininess is a property of the
|
||||
-- Pokemon rather than a display mode, and a Pokemon that is shiny in one
|
||||
-- player's save and not another's is not a Pokemon, it is a setting.
|
||||
--
|
||||
-- It rests on a fact the engine already ships. Gen 1 has no shininess of its
|
||||
-- own, but it has the four DVs Gen 2 reads to decide it, and
|
||||
-- src/pokemon/Stats.lua:90 carries that reading -- the engine's own comment
|
||||
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
||||
-- So nothing new is stored on a Pokemon and nothing has to migrate: every
|
||||
-- save ever made already contains the answer, and this only starts drawing
|
||||
-- it. See lib/Shiny.lua for why deriving beats storing.
|
||||
--
|
||||
-- Three seams, each in its own file with its own reasoning:
|
||||
-- ShinyBattle wraps Pokemon.new, which is where every wild, gift,
|
||||
-- starter and traded mon is built, so the roll lands before
|
||||
-- the sprite is baked
|
||||
-- ShinyUI the status page's mark, and the summary pic's palette
|
||||
-- ShinyPics the battle pic's palette -- a real recolour, baked into the
|
||||
-- image cache under a shiny key, on every rung that draws a
|
||||
-- pic (OFF, both 2D-3D rungs, and the cards a STADIUM battle
|
||||
-- still uses for a species with no model)
|
||||
-- ShinyFx the arrival sparkle for the STADIUM rungs (3D, armed from
|
||||
-- Stadium.update)
|
||||
-- ShinyFlash the same announcement for every OTHER rung, drawn in the
|
||||
-- Game Boy's own pixel grid over the pic
|
||||
--
|
||||
-- The Stadium models need no seam here at all: their recolour happens at
|
||||
-- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
|
||||
-- shiny pack.
|
||||
ShinyBattle.install()
|
||||
ShinyUI.install()
|
||||
ShinyPics.install()
|
||||
ShinyFlash.install()
|
||||
|
||||
-- ShinyPics needs to know WHICH Pokemon a pic is being built for, and the
|
||||
-- two palette functions it wraps are told only the species. The individual
|
||||
-- passes through here one call earlier: `pokemon.sprite` carries ctx.mon.
|
||||
--
|
||||
-- next() first and the return value untouched -- this reads the context and
|
||||
-- changes nothing about which art is chosen.
|
||||
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
|
||||
local out = next(path, ctx)
|
||||
pcall(ShinyPics.note, ctx)
|
||||
return out
|
||||
end)
|
||||
|
||||
-- A save opened for the first time under this mod has shiny Pokemon in it
|
||||
-- already -- they always did -- so refresh the cached flag across the party
|
||||
-- rather than leaving it absent until each mon next changes.
|
||||
mod.events:on("save.loaded", function() ShinyBattle.markParty() end)
|
||||
mod.events:on("save.created", function() ShinyBattle.markParty() end)
|
||||
|
||||
-- ------- the free-roam rungs' inputs and their walk
|
||||
--
|
||||
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
|
||||
@@ -1079,6 +1303,15 @@ end
|
||||
-- become the same eight buttons. See lib/Horde.lua.
|
||||
Horde.install()
|
||||
|
||||
-- ------- LET'S GO capture mode
|
||||
--
|
||||
-- After every other input seam on purpose: while a throw is being aimed
|
||||
-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is
|
||||
-- read before anything else can claim the pointer -- and outside the aim
|
||||
-- they forward every byte untouched. The battle-side wraps (throwBall,
|
||||
-- safariAction) and the experience hooks install here too.
|
||||
LetsGo.install()
|
||||
|
||||
-- ------- edge-anchored menus stay in the GB frame while a headset is live
|
||||
--
|
||||
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.6.2",
|
||||
"version": "1.8.2",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -36,8 +36,9 @@ return {
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1574
-106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
-- A flat top must not stamp its rim twice.
|
||||
--
|
||||
-- ChunkMesher.flatTopRow decides which drawn row a flat-topped volume's top
|
||||
-- face wears at each depth. Where the drawing is a RIM over a uniform body
|
||||
-- -- every cliff mound in the game, and the mound the Diglett's Cave mouth
|
||||
-- is cut into -- the rim belongs at the plateau's north edge and nowhere
|
||||
-- else. Cycling the first two rows lays it again every second tile.
|
||||
--
|
||||
-- The invariant: on such a run the sampled row never goes BACKWARDS as ty
|
||||
-- moves south. Art that genuinely repeats (the Safari Zone's fence
|
||||
-- alternates two tiles the whole way down) is exempt: there the repeat is
|
||||
-- what the drawing says, and the run is not rim-over-body.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/flat_top_test.lua lovec .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local V = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
V = V and V.lib
|
||||
local Structures = V and V.require("Structures")
|
||||
local ChunkMesher = V and V.require("ChunkMesher")
|
||||
if not (Structures and ChunkMesher and ChunkMesher.flatTopRow) then
|
||||
print("[flattop] FAIL mod, Structures or ChunkMesher.flatTopRow missing")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
|
||||
|
||||
local MAPS = {}
|
||||
for id in pairs((game.data and game.data.maps) or {}) do
|
||||
MAPS[#MAPS + 1] = id
|
||||
end
|
||||
table.sort(MAPS)
|
||||
|
||||
local checked, offenders, examples = 0, 0, {}
|
||||
for _, mapId in ipairs(MAPS) do
|
||||
U.teleport(game, mapId, 5, 5, "up")
|
||||
U.wait(6)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.def and ow.map.def.id == mapId then
|
||||
local map = ow.map
|
||||
local S = Structures.forMap(map)
|
||||
local seen = {}
|
||||
for tx = 0, map.def.width * 4 - 1 do
|
||||
for ty = 0, map.def.height * 4 - 1 do
|
||||
local run = S.runs[keyOf(tx, ty)]
|
||||
local sig = run and (tostring(run) .. ":" .. tx)
|
||||
if run and not seen[sig] and (run.rise or 0) == 0 then
|
||||
seen[sig] = true
|
||||
local ext = run.front - run.north + 1
|
||||
-- rim over a uniform body: the shape the rim must not repeat on
|
||||
local uniform = ext > 2
|
||||
if uniform then
|
||||
local body = map:tileAt(tx, run.north + 1)
|
||||
for d = 2, ext - 1 do
|
||||
if map:tileAt(tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if uniform then
|
||||
checked = checked + 1
|
||||
local prev = -1
|
||||
for ty2 = run.north, run.front do
|
||||
local row = ChunkMesher.flatTopRow(run, ty2)
|
||||
if row < prev then
|
||||
offenders = offenders + 1
|
||||
if #examples < 5 then
|
||||
examples[#examples + 1] = ("%s tx=%d north=%d ext=%d "
|
||||
.. "went back to row %d at ty %d")
|
||||
:format(mapId, tx, run.north, ext, row, ty2)
|
||||
end
|
||||
break
|
||||
end
|
||||
prev = row
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
print(("[flattop] %d rim-over-body runs checked, %d repeat their rim")
|
||||
:format(checked, offenders))
|
||||
for _, e in ipairs(examples) do print("[flattop] " .. e) end
|
||||
if offenders > 0 then
|
||||
print("[flattop] FAIL")
|
||||
love.event.quit(1)
|
||||
else
|
||||
print("[flattop] PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Probe: does WASD still walk CAMERA-RELATIVE on the 1ST/3RD rungs?
|
||||
--
|
||||
-- The regression report is "in first and third person the wasd keys now
|
||||
-- move in cardinal directions". Cardinal means the yaw is not being
|
||||
-- applied -- either FreeMove.tick is not the handler that ran (so the
|
||||
-- engine's grid walk did, which is cardinal by construction), or it ran
|
||||
-- and moveWorld got a yaw of zero.
|
||||
--
|
||||
-- So measure both: which handler took the frame, what the yaw was, and
|
||||
-- which way the player actually travelled for a held W.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/freemove_probe.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
U.log("DRAMATIC_SHAPE is not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
|
||||
-- FirstPerson captures the mouse only with WINDOW FOCUS, and a driver
|
||||
-- window never has it -- so without this the look reads as dead for a
|
||||
-- reason that has nothing to do with the code under test. Force the
|
||||
-- answer it gates on; setRelativeMode then arms and the relative-motion
|
||||
-- wrap claims the deltas exactly as it would for a player.
|
||||
love.window.hasFocus = function() return true end
|
||||
|
||||
-- count which walk handler actually takes the frames
|
||||
local ticks = 0
|
||||
local innerTick = FreeMove.tick
|
||||
FreeMove.tick = function(...)
|
||||
ticks = ticks + 1
|
||||
return innerTick(...)
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if FirstPerson.blend >= 1 and Voxel.ready then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- W is the UP button; the B button's key is "x" (see Input's
|
||||
-- DEFAULT_BINDINGS -- the driver must press KEYS, not button names, to
|
||||
-- exercise the real path)
|
||||
local function holdKey(k, frames)
|
||||
love.keypressed(k, k, false)
|
||||
U.wait(frames)
|
||||
love.keyreleased(k, k)
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
for _, rung in ipairs({ { "1ST", Voxel.FP_LEVEL }, { "3RD", Voxel.TP_LEVEL } }) do
|
||||
Pipelines.setLevel("voxel", rung[2])
|
||||
settle()
|
||||
-- face EAST: yaw is the free-roam look, and a camera-relative W must
|
||||
-- then walk +X. A cardinal W walks -Y (north) whatever the camera does.
|
||||
FirstPerson.yaw = math.pi / 2
|
||||
U.wait(10)
|
||||
|
||||
local ow = game.stack:top()
|
||||
local p = ow and ow.player
|
||||
if not p then U.log(rung[1] .. ": no player") break end
|
||||
local x0, y0 = p.px, p.py
|
||||
ticks = 0
|
||||
holdKey("w", 40)
|
||||
local dx, dy = p.px - x0, p.py - y0
|
||||
U.log(("%s: driving=%s freeMove ticks=%d yaw=%.2f W moved dx=%.1f dy=%.1f")
|
||||
:format(rung[1], tostring(FirstPerson.driving()), ticks,
|
||||
FirstPerson.yaw, dx, dy))
|
||||
local wx, wz = FirstPerson.moveWorld(0, 1)
|
||||
U.log(("%s: moveWorld(0,1) = %.2f,%.2f (want a mostly-X vector at this yaw)")
|
||||
:format(rung[1], wx, wz))
|
||||
|
||||
-- ------- and does the LOOK still turn?
|
||||
--
|
||||
-- A yaw that never moves is the same symptom from the player's seat:
|
||||
-- it stays at the cardinal angle the rung was entered on (FACING_ANGLE
|
||||
-- is one of four compass points), so W walks due north for ever and
|
||||
-- "wasd moves in cardinal directions" is exactly what it feels like.
|
||||
-- The capture mode's pointer wraps are installed OUTSIDE FirstPerson's,
|
||||
-- so this is the path that could have regressed.
|
||||
-- FirstPerson only claims relative motion while it has CAPTURED the
|
||||
-- mouse, and it captures only with window focus. A driver window that
|
||||
-- never got focus would show a dead look for a reason that has nothing
|
||||
-- to do with the code -- so record the discriminator rather than read
|
||||
-- a zero and blame the wrap.
|
||||
local okF, focus = pcall(function() return love.window.hasFocus() end)
|
||||
local okR, rel = pcall(function() return love.mouse.getRelativeMode() end)
|
||||
U.log(("%s: engaged=%s focus=%s relativeMode=%s")
|
||||
:format(rung[1], tostring(FirstPerson.engaged()),
|
||||
okF and tostring(focus) or "?",
|
||||
okR and tostring(rel) or "?"))
|
||||
|
||||
local before = FirstPerson.yaw
|
||||
for _ = 1, 10 do
|
||||
love.mousemoved(400, 300, 12, 0, false)
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
U.log(("%s: mouse look -- yaw %.3f -> %.3f (delta %.3f)%s")
|
||||
:format(rung[1], before, FirstPerson.yaw, FirstPerson.yaw - before,
|
||||
math.abs(FirstPerson.yaw - before) < 1e-6
|
||||
and " <-- THE LOOK IS DEAD" or ""))
|
||||
end
|
||||
|
||||
-- ------- and now the way a PLAYER gets there: the "3" hotkey
|
||||
--
|
||||
-- Pipelines.setLevel above is the driver's shortcut. A player cycles the
|
||||
-- rung with 3, which goes through the mod's own cycleVoxel. If that
|
||||
-- leaves Voxel.level disagreeing with the pipeline's level, the camera
|
||||
-- can be first-person while FreeMove.engaged() says no -- and then the
|
||||
-- ENGINE's grid handler takes the frame, which walks cardinally. That is
|
||||
-- the reported symptom exactly, so it is worth entering the rung the
|
||||
-- same way the report did.
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
U.wait(20)
|
||||
for i = 1, 8 do
|
||||
love.keypressed("3", "3", false)
|
||||
U.wait(3)
|
||||
love.keyreleased("3", "3")
|
||||
U.wait(12)
|
||||
local lvl = Pipelines.level("voxel")
|
||||
U.log(("hotkey 3 x%d -> pipeline level=%s Voxel.level=%s freeCam=%s "
|
||||
.. "engaged=%s driving=%s")
|
||||
:format(i, tostring(lvl), tostring(Voxel.level),
|
||||
tostring(Voxel.isFreeCam(Voxel.level)),
|
||||
tostring(FirstPerson.engaged()),
|
||||
tostring(FirstPerson.driving())))
|
||||
if Voxel.isFreeCam(Voxel.level) then
|
||||
settle()
|
||||
local ow = game.stack:top()
|
||||
local p = ow and ow.player
|
||||
FirstPerson.yaw = math.pi / 2
|
||||
U.wait(6)
|
||||
local yaw0 = FirstPerson.yaw
|
||||
local x0, y0 = p.px, p.py
|
||||
ticks = 0
|
||||
holdKey("w", 30)
|
||||
U.log((" walked from the HOTKEY rung: ticks=%d yaw=%.2f dx=%.1f dy=%.1f%s")
|
||||
:format(ticks, yaw0, p.px - x0, p.py - y0,
|
||||
ticks == 0 and " <-- ENGINE GRID WALK (cardinal)" or ""))
|
||||
end
|
||||
end
|
||||
|
||||
FreeMove.tick = innerTick
|
||||
U.log("done")
|
||||
end
|
||||
@@ -0,0 +1,367 @@
|
||||
-- Driver: the two edges the capture mode has to hold on to.
|
||||
--
|
||||
-- A FULL with an EMPTY BAG. The encounter must still be a Let's Go
|
||||
-- encounter -- head-on seat, no player Pokemon, no classic menu --
|
||||
-- with the readout saying there is nothing to throw and RUN as the
|
||||
-- way out. The failure this catches is the old behaviour: falling
|
||||
-- back to the battle menu, which under FULL offers a FIGHT against a
|
||||
-- foe that never takes a turn.
|
||||
--
|
||||
-- B FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to
|
||||
-- fall short: the miss must land in the empty hand rather than
|
||||
-- tearing the session down.
|
||||
--
|
||||
-- C The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK
|
||||
-- and the PIKACHU are the same makeOldManDemo). LET'S GO must not
|
||||
-- touch it at any rung -- no session, no held camera, no veil -- and
|
||||
-- it must play its scripted throw through to its own ending.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/letsgo \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
local CatchThrow = lib.require("CatchThrow")
|
||||
local BattleScene = lib.require("BattleScene")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
LetsGo.setting:setValue("full", game)
|
||||
if os.getenv("DS_RUNG") == "cards" then
|
||||
lib.require("OverworldBattle").setting:setValue(true, game)
|
||||
U.log("3D-BTL forced to 2D-3D A")
|
||||
end
|
||||
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
|
||||
|
||||
-- every ball out of the bag, whatever the save arrived with
|
||||
local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
|
||||
local function emptyBag()
|
||||
for _, id in ipairs(BALLS) do
|
||||
local n = game.save.inventory[id] or 0
|
||||
if n > 0 then pcall(Bag.remove, game.save, id, n) end
|
||||
game.save.inventory[id] = nil
|
||||
end
|
||||
end
|
||||
local function ballCount()
|
||||
local n = 0
|
||||
for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end
|
||||
return n
|
||||
end
|
||||
|
||||
-- A REAL key event, not U.tap's synthetic pressQueue inject. The bug
|
||||
-- this driver has to be able to see (button edges read on the render
|
||||
-- clock instead of the logic step) lives between love.keypressed and
|
||||
-- whoever polls the edge, so a driver that writes the queue itself
|
||||
-- jumps straight over it.
|
||||
--
|
||||
-- The B BUTTON's keyboard binding is "x" (or backspace) -- Input's
|
||||
-- DEFAULT_BINDINGS. The `b = "b"` next to it is the GAMEPAD table, so a
|
||||
-- driver that presses the "b" KEY presses nothing at all and reports a
|
||||
-- dead button whatever the code does.
|
||||
local B_KEY = "x"
|
||||
local function key(name)
|
||||
love.keypressed(name, name, false)
|
||||
U.wait(2)
|
||||
love.keyreleased(name, name)
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(90)
|
||||
|
||||
-- drive the intro chatter until the menu would open (which under FULL is
|
||||
-- when capture mode takes over instead)
|
||||
local function toCapture(battle)
|
||||
U.wait(70)
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "menu" or CatchThrow.session() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- Tap the fight all the way off the stack before the next case starts.
|
||||
-- Cases that merely tapped A a fixed number of times left the previous
|
||||
-- battle (and its session) alive whenever an outcome ran long, and the
|
||||
-- next case then measured the leftover -- which reads as that case
|
||||
-- failing, at a spot nowhere near the cause.
|
||||
local function closeOut(battle)
|
||||
for _ = 1, 400 do
|
||||
if not CatchThrow.session() and game.stack:top() ~= battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local function describe(tag, battle)
|
||||
local s = CatchThrow.session()
|
||||
U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
|
||||
:format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-",
|
||||
s and s.phase or "-", tostring(battle.phase),
|
||||
BattleScene.capture and "up" or "down",
|
||||
tostring(BattleScene.capture
|
||||
and BattleScene.capture.hidePlayer)))
|
||||
return s
|
||||
end
|
||||
|
||||
-- ------- A: an empty bag still gets the capture screen
|
||||
|
||||
emptyBag()
|
||||
U.log("== A: FULL with " .. ballCount() .. " balls")
|
||||
local a = BattleState.newWild(game, "PIDGEY", 5)
|
||||
a.onFinish = function() end
|
||||
game.overworld:pushBattle(a)
|
||||
toCapture(a)
|
||||
|
||||
local s = describe("A", a)
|
||||
U.log(("A: hand is empty -> %s (want yes)"):format(
|
||||
(s and s.empty) and "yes" or "NO"))
|
||||
U.shot(game, DIR .. "/empty_1_aim.png")
|
||||
|
||||
-- and B is the way out: a Let's Go wild always escapes
|
||||
key(B_KEY)
|
||||
for _ = 1, 120 do
|
||||
U.wait(2)
|
||||
if a.result then break end
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result)))
|
||||
-- the session and the held camera are swept by battle.ended, which is the
|
||||
-- teardown BELOW this, not the moment `result` is written -- so the sweep
|
||||
-- is only worth asserting once the battle has actually left the stack
|
||||
closeOut(a)
|
||||
U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
|
||||
:format(CatchThrow.session() and "still up" or "gone",
|
||||
BattleScene.capture and "still up" or "down"))
|
||||
|
||||
-- ------- B: the LAST ball, thrown short
|
||||
|
||||
Bag.add(game.save, "POKE_BALL", 1, game.data)
|
||||
CatchThrow.lastBall = "POKE_BALL"
|
||||
U.log("== B: FULL with " .. ballCount() .. " ball")
|
||||
local b = BattleState.newWild(game, "PIDGEY", 5)
|
||||
b.onFinish = function() end
|
||||
-- The outcome under test is what happens when the LAST ball MISSES, so
|
||||
-- the roll must not be left to chance: a run where the Pidgey happened
|
||||
-- to be caught used to cascade into every later case (the session lived
|
||||
-- on into its epilogue, and C and D then measured that leftover instead
|
||||
-- of their own). Forced failure, three shakes -- the roll is the
|
||||
-- engine's business and is covered elsewhere.
|
||||
b.catchAttempt = function() return false, 3 end
|
||||
game.overworld:pushBattle(b)
|
||||
toCapture(b)
|
||||
describe("B", b)
|
||||
|
||||
-- a deliberately feeble swipe: it must fall short of the Pokemon, so
|
||||
-- the miss is the outcome under test rather than a lucky catch
|
||||
local aim = CatchThrow._aimInfo()
|
||||
if aim then
|
||||
local uw, uh = love.graphics.getDimensions()
|
||||
local function toWin(gx, gy)
|
||||
return (aim.lx + gx * aim.scale) * uw / aim.pw,
|
||||
(aim.ly + gy * aim.scale) * uh / aim.ph
|
||||
end
|
||||
local hx, hy = aim.hand[1], aim.hand[2]
|
||||
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
dx, dy = dx / d, dy / d
|
||||
local step = 120 / 60
|
||||
local px, py = toWin(hx, hy)
|
||||
love.mousepressed(px, py, 1, false, 1)
|
||||
for i = 1, 8 do
|
||||
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
|
||||
love.mousemoved(wx, wy, 0, 0, false)
|
||||
U.wait(1)
|
||||
end
|
||||
local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8)
|
||||
love.mousereleased(wx, wy, 1, false, 1)
|
||||
else
|
||||
U.log("B: NO AIM INFO -- capture mode did not open")
|
||||
end
|
||||
|
||||
-- ride the throw out, then keep tapping through the miss text until the
|
||||
-- hand is refilled -- which, with the bag now empty, means the EMPTY hand
|
||||
for _ = 1, 400 do
|
||||
U.wait(2)
|
||||
local q = CatchThrow.session()
|
||||
if q and q.phase == "aim" and q.empty then break end
|
||||
if not q then break end
|
||||
if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end
|
||||
end
|
||||
local sb = describe("B", b)
|
||||
U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)")
|
||||
:format(ballCount(),
|
||||
(sb and sb.empty) and "empty hand" or
|
||||
(sb and ("still holding " .. tostring(sb.ballId)))
|
||||
or "SESSION GONE"))
|
||||
U.shot(game, DIR .. "/empty_2_ranout.png")
|
||||
key(B_KEY)
|
||||
for _ = 1, 120 do
|
||||
U.wait(2)
|
||||
if b.result then break end
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
|
||||
closeOut(b)
|
||||
|
||||
-- ------- C: the scripted tutorial, untouched
|
||||
|
||||
Bag.add(game.save, "POKE_BALL", 10, game.data)
|
||||
U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls")
|
||||
local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
|
||||
local c = BattleState.newWild(game, om.species, om.level)
|
||||
c:makeOldManDemo()
|
||||
c.onFinish = function() end
|
||||
U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c))
|
||||
.. " fullWild -> " .. tostring(LetsGo.fullWild(c))
|
||||
.. " wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c)))
|
||||
game.overworld:pushBattle(c)
|
||||
|
||||
-- the demo drives ITSELF: the cursor, the bag and the throw are all
|
||||
-- scripted, so this only watches. Any session or veil appearing here is
|
||||
-- the failure.
|
||||
local sawSession, sawVeil = false, false
|
||||
local shotDemo = false
|
||||
for i = 1, 900 do
|
||||
if CatchThrow.session() then sawSession = true end
|
||||
if BattleScene.capture then sawVeil = true end
|
||||
if not shotDemo and i > 200 then
|
||||
shotDemo = true
|
||||
U.shot(game, DIR .. "/empty_3_oldman.png")
|
||||
end
|
||||
if c.result then break end
|
||||
U.wait(2)
|
||||
-- the scripted beats want A only to page the text along
|
||||
if i % 3 == 0 then U.tap(game, "a") end
|
||||
end
|
||||
U.log(("C: session ever opened=%s (want no) veil ever up=%s (want no)")
|
||||
:format(tostring(sawSession), tostring(sawVeil)))
|
||||
U.log(("C: result=%s balls=%d (want 10 -- the demo consumes none)")
|
||||
:format(tostring(c.result), ballCount()))
|
||||
U.log(("C: party still %d, first is %s")
|
||||
:format(#game.save.party,
|
||||
game.data.pokemon[game.save.party[1].species].name))
|
||||
|
||||
-- ------- D: B RUNS, with a ball in hand, on a frame that runs several
|
||||
-- logic steps
|
||||
--
|
||||
-- The reported failure, reproduced rather than reasoned about. Input:step
|
||||
-- rebuilds the edge table once per FIXED step; Game:update runs the
|
||||
-- frame's steps first and the render-clock hooks after, so any frame
|
||||
-- carrying more than one step has already discarded the earlier steps'
|
||||
-- edges. Below 60fps -- which is where a 3D battle lives -- that is
|
||||
-- every press. speedOverride multiplies the logic clock, so it packs
|
||||
-- several steps into each frame on demand and turns "sometimes, on a
|
||||
-- slow machine" into "every time, here".
|
||||
for _, speed in ipairs({ 1, 4 }) do
|
||||
Bag.add(game.save, "POKE_BALL", 5, game.data)
|
||||
CatchThrow.lastBall = "POKE_BALL"
|
||||
game.speedOverride = speed > 1 and speed or nil
|
||||
U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed))
|
||||
local d = BattleState.newWild(game, "PIDGEY", 5)
|
||||
d.onFinish = function() end
|
||||
game.overworld:pushBattle(d)
|
||||
toCapture(d)
|
||||
local sd = describe("D" .. speed, d)
|
||||
if sd and not sd.empty then
|
||||
-- where does the key actually get to? Each stage of the chain, so a
|
||||
-- dead B is attributed rather than guessed at
|
||||
local top = game.stack and game.stack:top()
|
||||
U.log(("D%dX probe: top=%s onKeyPressed=%s fullWild=%s declinable=%s")
|
||||
:format(speed, tostring(top and top.screenId or "?"),
|
||||
tostring(top and top.onKeyPressed ~= nil),
|
||||
tostring(sd.fullWild), tostring(sd.declinable)))
|
||||
-- ------- the counter-factual, measured rather than argued
|
||||
--
|
||||
-- How many times an edge would have been visible to a poll on the
|
||||
-- RENDER clock -- where these reads used to live. Measured on UP
|
||||
-- rather than B: the fix TAKES B out of the queue, so B never
|
||||
-- reaches `pressed` any more and would read as a false zero. UP is
|
||||
-- untouched by everything while the battle is parked, and the
|
||||
-- question is about step-vs-frame ordering, not about which button.
|
||||
local onFrame, onStep = 0, 0
|
||||
local innerU = CatchThrow.update
|
||||
CatchThrow.update = function(dt)
|
||||
if game.input.wasPressed and game.input:wasPressed("up") then
|
||||
onFrame = onFrame + 1
|
||||
end
|
||||
return innerU(dt)
|
||||
end
|
||||
local innerB0 = CatchThrow.buttons
|
||||
CatchThrow.buttons = function(g)
|
||||
local q = g and g.input and g.input.pressQueue
|
||||
if q then
|
||||
for i = 1, #q do if q[i] == "up" then onStep = onStep + 1 end end
|
||||
end
|
||||
return innerB0(g)
|
||||
end
|
||||
love.keypressed("up", "up", false)
|
||||
U.wait(2)
|
||||
love.keyreleased("up", "up")
|
||||
U.wait(2)
|
||||
CatchThrow.buttons = innerB0
|
||||
CatchThrow.update = innerU
|
||||
U.log(("D%dX counter-factual: one UP press -- the logic step saw it "
|
||||
.. "%d time(s), the RENDER clock %d time(s)%s")
|
||||
:format(speed, onStep, onFrame,
|
||||
onFrame == 0 and " <-- a frame poll misses it entirely"
|
||||
or ""))
|
||||
|
||||
local seen = 0
|
||||
local innerB = CatchThrow.buttons
|
||||
CatchThrow.buttons = function(g)
|
||||
local q = g and g.input and g.input.pressQueue
|
||||
if q and #q > 0 then
|
||||
seen = seen + 1
|
||||
U.log("D probe: buttons saw queue [" .. table.concat(q, ",") .. "]")
|
||||
end
|
||||
return innerB(g)
|
||||
end
|
||||
love.keypressed(B_KEY, B_KEY, false)
|
||||
U.log(("D probe: right after keypressed -- queue=[%s] state.b=%s")
|
||||
:format(table.concat(game.input.pressQueue, ","),
|
||||
tostring(game.input.state.b)))
|
||||
U.wait(2)
|
||||
love.keyreleased(B_KEY, B_KEY)
|
||||
U.wait(2)
|
||||
CatchThrow.buttons = innerB
|
||||
U.log("D probe: buttons saw a non-empty queue " .. seen .. " time(s)")
|
||||
local ran = false
|
||||
for _ = 1, 60 do
|
||||
U.wait(2)
|
||||
if d.result then ran = true break end
|
||||
end
|
||||
U.log(("D%dX: after a REAL B -- result=%s (want run) %s")
|
||||
:format(speed, tostring(d.result),
|
||||
ran and "RAN" or "*** B DID NOTHING ***"))
|
||||
else
|
||||
U.log("D" .. speed .. ": no armed session to test")
|
||||
end
|
||||
-- escape hatch: a B that did nothing leaves the battle parked in the
|
||||
-- capture phase forever, and the rest of the run must still report
|
||||
if not d.result then
|
||||
pcall(CatchThrow.onBattleEnded)
|
||||
d.result, d.phase, d.afterQueue = "run", "messages", "finish"
|
||||
end
|
||||
closeOut(d)
|
||||
game.speedOverride = nil
|
||||
end
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Probe: the reported grass along the TOP of the frame in GO-style
|
||||
-- battles on ROUTE 6.
|
||||
--
|
||||
-- The capture seat is a different camera from the battle's own: the "tele"
|
||||
-- rig stands 145 world px back at a height of 37.9 (well over two cells,
|
||||
-- clear of anything that grows on the ground), while the head-on capture
|
||||
-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass
|
||||
-- tuft or hedge. On a route lined with the stuff, the eye is inside it.
|
||||
--
|
||||
-- So: stage a capture at several spots along Route 6 and record where the
|
||||
-- eye actually is relative to the ground, with a shot of each.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/route6 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
local CatchThrow = lib.require("CatchThrow")
|
||||
local ChunkMesher = lib.require("ChunkMesher")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||
game.save.player.name = "RED"
|
||||
Bag.add(game.save, "POKE_BALL", 99, game.data)
|
||||
CatchThrow.lastBall = "POKE_BALL"
|
||||
LetsGo.setting:setValue("full", game)
|
||||
|
||||
local MAP = os.getenv("DS_MAP") or "ROUTE_6"
|
||||
-- a spread down the route: the reported shot is on the path with hedges
|
||||
-- both sides, which is where a low seat has the least room
|
||||
local SPOTS = {}
|
||||
for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do
|
||||
SPOTS[#SPOTS + 1] = xy
|
||||
end
|
||||
|
||||
for i, sp in ipairs(SPOTS) do
|
||||
U.teleport(game, MAP, sp[1], sp[2], "up")
|
||||
U.wait(50)
|
||||
-- confirm the teleport actually landed: a first cut reported the SAME
|
||||
-- eye at all five spots, which meant the probe never left the save's
|
||||
-- own position and every "spot" was one place wearing five labels
|
||||
do
|
||||
local ow = game.overworld
|
||||
local m = ow and ow.map
|
||||
U.log(("spot %d: on map %s at cell %s,%s")
|
||||
:format(i, tostring(m and (m.id or m.name) or "?"),
|
||||
tostring(ow and ow.player and ow.player.cellX),
|
||||
tostring(ow and ow.player and ow.player.cellY)))
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
local bt = BattleState.newWild(game, "ODDISH", 13)
|
||||
bt.onFinish = function() end
|
||||
game.overworld:pushBattle(bt)
|
||||
U.wait(70)
|
||||
for _ = 1, 80 do
|
||||
if bt.phase == "menu" or CatchThrow.session() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(40)
|
||||
|
||||
local s = CatchThrow.session()
|
||||
if s and s.shot and s.shot.eye then
|
||||
local e = s.shot.eye
|
||||
local p = s.playerPos
|
||||
local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2)
|
||||
U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f height %.1f over ground "
|
||||
.. "seat %.1f back (wants 46)%s")
|
||||
:format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back,
|
||||
back < 45.5 and " <-- the world pulled it in" or ""))
|
||||
else
|
||||
U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2]))
|
||||
end
|
||||
U.shot(game, ("%s/spot%d.png"):format(DIR, i))
|
||||
|
||||
-- out, and all the way off the stack before the next spot
|
||||
U.tap(game, "b")
|
||||
for _ = 1, 300 do
|
||||
if not CatchThrow.session() and game.stack:top() ~= bt then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,309 @@
|
||||
-- Driver: photograph the LET'S GO capture mode end to end.
|
||||
--
|
||||
-- One wild encounter under FULL: the battle menu should never be seen --
|
||||
-- capture mode opens over it with the ball hanging at the player's empty
|
||||
-- cell and the ring pulsing on the foe -- then a synthetic mouse flick
|
||||
-- throws the ball and the beats that follow are photographed: flight,
|
||||
-- the open-mouth suck, the drop-and-wobble, and the outcome.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/letsgo \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_shots.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- SHOT_DIR must already exist. DS_LETSGO_MODE=catching runs the same
|
||||
-- beats through the bag-menu route instead of the FULL auto-entry.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
||||
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
local CatchThrow = lib.require("CatchThrow")
|
||||
local BattleScene = lib.require("BattleScene")
|
||||
|
||||
-- a spread of levels, because the Let's Go award pays every party
|
||||
-- member against its OWN level: a low one should pull far more from
|
||||
-- the same catch than a high one, and a flat payout would hide that
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
Pokemon.new(game.data, "RATTATA", 5),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
Bag.add(game.save, "POKE_BALL", 20, game.data)
|
||||
Bag.add(game.save, "GREAT_BALL", 5, game.data)
|
||||
-- DS_BALL forces the ball, so a MASTER_BALL run is a guaranteed catch
|
||||
-- and the experience payout can be read deterministically
|
||||
local FORCE = os.getenv("DS_BALL")
|
||||
if FORCE then
|
||||
Bag.add(game.save, FORCE, 5, game.data)
|
||||
CatchThrow.lastBall = FORCE
|
||||
end
|
||||
local expBefore = {}
|
||||
for i, m in ipairs(game.save.party) do
|
||||
expBefore[i] = { exp = m.exp, level = m.level }
|
||||
end
|
||||
LetsGo.setting:setValue(MODE, game)
|
||||
-- DS_RUNG=cards forces the 2D-3D A rung: the control run for the pic
|
||||
-- measurement path, against the STADIUM models the save may be on
|
||||
if os.getenv("DS_RUNG") == "cards" then
|
||||
lib.require("OverworldBattle").setting:setValue(true, game)
|
||||
U.log("3D-BTL forced to 2D-3D A")
|
||||
end
|
||||
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(90)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
-- the wipe and the "Wild X appeared!" chatter: tap until the menu (or
|
||||
-- capture mode, which under FULL opens the instant the menu would)
|
||||
U.wait(70)
|
||||
for i = 1, 80 do
|
||||
if battle.phase == "menu" or CatchThrow.session() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
if i % 20 == 0 then
|
||||
U.log(("still driving intro: phase=%s queue=%d anim=%s")
|
||||
:format(tostring(battle.phase), #(battle.queue or {}),
|
||||
tostring(battle.animPlaying)))
|
||||
end
|
||||
end
|
||||
|
||||
local function phase()
|
||||
local s = CatchThrow.session()
|
||||
return s and s.phase or ("battle:" .. tostring(battle.phase))
|
||||
end
|
||||
|
||||
if MODE == "catching" then
|
||||
-- the bag route: ITEM on the menu, then the ball
|
||||
U.wait(20)
|
||||
U.log("menu phase: " .. tostring(battle.phase))
|
||||
-- cursor to ITEM (menu is 2x2: fight pkmn / item run) -- down once
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(20)
|
||||
-- the bag opens on the first item; balls were added first
|
||||
U.tap(game, "a"); U.wait(10)
|
||||
else
|
||||
-- FULL: capture mode should have opened on its own
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
U.log("capture phase: " .. phase())
|
||||
do
|
||||
local s = CatchThrow.session()
|
||||
if s then
|
||||
local b = s.artBox
|
||||
U.log(("probe: span=%.1f enemyGB=%.0f,%.0f playerGB=%.0f,%.0f")
|
||||
:format(s.shot.enemySpan or -1, s.shot.enemy[1], s.shot.enemy[2],
|
||||
s.shot.player[1], s.shot.player[2]))
|
||||
U.log(("probe: eye=%.0f,%.0f,%.0f enemyW=%.0f,%.0f playerW=%.0f,%.0f")
|
||||
:format(s.shot.eye[1], s.shot.eye[2], s.shot.eye[3],
|
||||
s.enemyPos[1], s.enemyPos[3],
|
||||
s.playerPos[1], s.playerPos[3]))
|
||||
if b then
|
||||
U.log(("probe: artBox ax=%d ay=%d box=%d..%d,%d..%d body r=%.1f yOff=%.1f")
|
||||
:format(b.ax, b.ay, b.x0, b.x1, b.y0, b.y1,
|
||||
s.body.r, s.body.yOff))
|
||||
else
|
||||
U.log(("probe: artBox=nil body r=%.1f yOff=%.1f")
|
||||
:format(s.body.r, s.body.yOff))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.shot(game, DIR .. "/1_aim.png")
|
||||
|
||||
-- ------- the reach test
|
||||
--
|
||||
-- Drag the ball to the far corners of the WINDOW -- including well
|
||||
-- below where the battle's text box used to be -- and report where it
|
||||
-- actually ends up in the GB frame. A fence shows up here as a ball
|
||||
-- that stops moving while the pointer keeps going.
|
||||
do
|
||||
local W, H = love.graphics.getDimensions()
|
||||
local s = CatchThrow.session()
|
||||
if s and love.mousepressed then
|
||||
local hx, hy = s.handGB[1], s.handGB[2]
|
||||
love.mousepressed(W * 0.5, H * 0.62, 1, false, 1)
|
||||
U.wait(2)
|
||||
local CORNERS = {
|
||||
{ "bottom-centre", 0.50, 0.97 },
|
||||
{ "bottom-left", 0.06, 0.97 },
|
||||
{ "bottom-right", 0.94, 0.97 },
|
||||
{ "top-left", 0.06, 0.10 },
|
||||
}
|
||||
for _, c in ipairs(CORNERS) do
|
||||
for i = 1, 4 do
|
||||
if love.mousemoved then
|
||||
love.mousemoved(W * c[2], H * c[3], 0, 0, false)
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local q = CatchThrow.session()
|
||||
U.log(("reach %-14s -> ball GB %.0f,%.0f (frame is 160x144)")
|
||||
:format(c[1], q and q.handGB[1] or -1, q and q.handGB[2] or -1))
|
||||
if c[1] == "bottom-centre" then
|
||||
U.shot(game, DIR .. "/1b_drag_low.png")
|
||||
end
|
||||
end
|
||||
-- a slow release is a dead flick: nothing is thrown
|
||||
if love.mousereleased then
|
||||
love.mousereleased(W * 0.06, H * 0.10, 1, false, 1)
|
||||
end
|
||||
U.wait(30)
|
||||
U.log("after reach test: " .. phase()
|
||||
.. (" (ball home was %.0f,%.0f)"):format(hx, hy))
|
||||
end
|
||||
end
|
||||
|
||||
-- A synthetic flick aimed like a hand: from the session's own geometry,
|
||||
-- pick a release point short of the ring and a sweep speed for a
|
||||
-- comfortable sigma, so the aim model (release + reach along the flick)
|
||||
-- lands the ball on the ring centre. Window units; the session maps
|
||||
-- them back to GB through the live letterbox.
|
||||
local aim = CatchThrow._aimInfo()
|
||||
if aim then
|
||||
local uw, uh = love.graphics.getDimensions()
|
||||
local function toWin(gx, gy)
|
||||
return (aim.lx + gx * aim.scale) * uw / aim.pw,
|
||||
(aim.ly + gy * aim.scale) * uh / aim.ph
|
||||
end
|
||||
-- the throw is the swipe's own velocity now: grab the ball and sweep
|
||||
-- toward the ring at a comfortable ~190 GB px/s for 8 frames.
|
||||
-- DS_SWIPE overrides it, which is how the strength band is swept:
|
||||
-- weak should fall short, comfortable should land, hard should still
|
||||
-- reach rather than sail over.
|
||||
local SPEED = tonumber(os.getenv("DS_SWIPE") or "") or 190
|
||||
local FRAMES = 8
|
||||
SPEED_USED = SPEED
|
||||
local hx, hy = aim.hand[1], aim.hand[2]
|
||||
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
dx, dy = dx / d, dy / d
|
||||
U.log(("aim: hand %.0f,%.0f ring %.0f,%.0f outer %.0f")
|
||||
:format(hx, hy, aim.ring[1], aim.ring[2], aim.outer))
|
||||
local step = SPEED / 60
|
||||
if love.mousepressed then
|
||||
local px, py = toWin(hx, hy)
|
||||
love.mousepressed(px, py, 1, false, 1)
|
||||
end
|
||||
for i = 1, FRAMES do
|
||||
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
|
||||
if love.mousemoved then love.mousemoved(wx, wy, 0, 0, false) end
|
||||
U.wait(1)
|
||||
end
|
||||
if love.mousereleased then
|
||||
local wx, wy = toWin(hx + dx * step * FRAMES, hy + dy * step * FRAMES)
|
||||
love.mousereleased(wx, wy, 1, false, 1)
|
||||
end
|
||||
-- what actually left the hand, before gravity has touched it
|
||||
local s0 = CatchThrow.session()
|
||||
if s0 and s0.vel then
|
||||
local p = s0.ballInst.pos
|
||||
U.log(("LAUNCH fwd/up/lat = %.1f %.1f %.1f from height %.1f, %.1f px out")
|
||||
:format(math.sqrt(s0.vel[1] ^ 2 + s0.vel[3] ^ 2), s0.vel[2], 0,
|
||||
p[2] - s0.groundY,
|
||||
math.sqrt((s0.enemyPos[1] - p[1]) ^ 2
|
||||
+ (s0.enemyPos[3] - p[3]) ^ 2)))
|
||||
else
|
||||
U.log("LAUNCH -- nothing was thrown (flick rejected)")
|
||||
end
|
||||
else
|
||||
U.log("NO AIM INFO -- capture mode did not open")
|
||||
end
|
||||
|
||||
U.wait(4)
|
||||
-- did it CONNECT? poll the flight out and say plainly which way it
|
||||
-- went, so a strength sweep reads as a table instead of a guess
|
||||
do
|
||||
local verdict, peak = "no throw", 0
|
||||
for _ = 1, 200 do
|
||||
local s = CatchThrow.session()
|
||||
if not s then verdict = "session gone" break end
|
||||
if s.phase == "flight" then
|
||||
local h = s.ballInst.pos[2] - s.groundY
|
||||
if h > peak then peak = h end
|
||||
verdict = "MISS (fell short / wide)"
|
||||
elseif s.phase == "suck" or s.phase == "drop"
|
||||
or s.phase == "wobble" then
|
||||
verdict = "HIT" .. (s.tier and (" " .. s.tier) or " (outside ring)")
|
||||
break
|
||||
elseif s.phase ~= "aim" then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local body = CatchThrow.session() and CatchThrow.session().body
|
||||
U.log(("THROW swipe=%d -> %s (apex %.1f world px, body centre %.1f)")
|
||||
:format(SPEED_USED or -1, verdict, peak, body and body.yOff or -1))
|
||||
end
|
||||
U.log("after flick: " .. phase())
|
||||
U.shot(game, DIR .. "/2_flight.png")
|
||||
|
||||
-- the suck: ball open at the foe, beam on, mon shrinking
|
||||
for _ = 1, 60 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if s and s.phase == "suck" then break end
|
||||
if not CatchThrow.session() then break end
|
||||
end
|
||||
U.log("suck check: " .. phase()
|
||||
.. " shrink=" .. tostring(BattleScene.capture
|
||||
and BattleScene.capture.shrink))
|
||||
U.shot(game, DIR .. "/3_suck.png")
|
||||
|
||||
-- the drop and the first wobble
|
||||
for _ = 1, 120 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if s and s.phase == "wobble" then break end
|
||||
if not s then break end
|
||||
end
|
||||
U.wait(30)
|
||||
U.log("wobble check: " .. phase()
|
||||
.. " shrink=" .. tostring(BattleScene.capture
|
||||
and BattleScene.capture.shrink))
|
||||
U.shot(game, DIR .. "/4_wobble.png")
|
||||
|
||||
-- the outcome: stars or burst, then the engine's own text
|
||||
for _ = 1, 300 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if not s or s.phase == "epilogue" or s.phase == "burst" then break end
|
||||
end
|
||||
U.wait(20)
|
||||
U.log("outcome: " .. phase())
|
||||
U.shot(game, DIR .. "/5_outcome.png")
|
||||
|
||||
-- under FULL a breakout must land straight back in throw mode -- no
|
||||
-- enemy turn between; a catch plays out its epilogue instead
|
||||
-- keep tapping through the caught chatter so storeCaughtMon (and the
|
||||
-- experience payout hanging off it) actually runs
|
||||
for _ = 1, 60 do U.tap(game, "a"); U.wait(6) end
|
||||
U.log(("after outcome: %s battle=%s balls=%d")
|
||||
:format(phase(), tostring(battle.phase),
|
||||
game.save.inventory.POKE_BALL or 0))
|
||||
for i, m in ipairs(game.save.party) do
|
||||
local was = expBefore[i]
|
||||
local nm = game.data.pokemon[m.species].name
|
||||
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
|
||||
:format(nm, was.level, m.level, was.exp, m.exp, m.exp - was.exp))
|
||||
end
|
||||
local combo = lib.require("LetsGo").combo()
|
||||
U.log("combo: " .. (combo and (combo.species .. " x" .. combo.count)
|
||||
or "none"))
|
||||
U.shot(game, DIR .. "/6_after.png")
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Driver: does a TRAINER knockout pay the whole party under LET'S GO FULL?
|
||||
--
|
||||
-- The catch payout is easy to see (one throw, one award). A knockout is
|
||||
-- not: it goes through the engine's own faint -> awardExp path, which is
|
||||
-- the one this mode replaces wholesale. So fight a real trainer with a
|
||||
-- deliberately uneven party and read the deltas -- every member should
|
||||
-- gain, and the low ones should gain multiples of the high one.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_trainer_exp.lua \
|
||||
-- SHOT_DIR=.scratchpad/letsgo "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- DS_LETSGO_MODE=off runs the same fight on the engine's own rules, which
|
||||
-- is the control: there, only the Pokemon that fought should gain.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
LetsGo.setting:setValue(MODE == "off" and false or MODE, game)
|
||||
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
|
||||
|
||||
-- count the summary cards at their SOURCE rather than by catching them
|
||||
-- on the stack: the driver taps fast enough to dismiss one between two
|
||||
-- polls, and an absence there would look like a card that never came
|
||||
local ExpPanel = lib.require("ExpPanel")
|
||||
local innerNew, cards = ExpPanel.new, 0
|
||||
ExpPanel.new = function(g, rows)
|
||||
local panel = innerNew(g, rows)
|
||||
cards = cards + 1
|
||||
-- read at DRAW time in the real thing; here the loop that fills it
|
||||
-- has already run, so peeking now is honest
|
||||
local parts = {}
|
||||
for _, r in ipairs(rows or {}) do
|
||||
parts[#parts + 1] = ("%s+%d%s"):format(
|
||||
(g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0,
|
||||
(r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "")
|
||||
end
|
||||
U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " ")))
|
||||
CARD_HOLD = 20 -- frames to stop tapping, so it can be shot
|
||||
return panel
|
||||
end
|
||||
|
||||
-- one strong fighter and two bystanders: only the fighter gains on the
|
||||
-- engine's own rules, so the bystanders ARE the test
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
Pokemon.new(game.data, "RATTATA", 5),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- The WEAKEST party in the game: one Pokemon, lowest level. Picked by
|
||||
-- measuring rather than by name -- an alphabetical first pick lands on
|
||||
-- OPP_AGATHA, whose Elite Four ghosts a level 45 Charizard does not
|
||||
-- reliably clear, and a fight that never resolves reads exactly like a
|
||||
-- payout that never happened.
|
||||
local class, partyIx, best = nil, 1, nil
|
||||
local ids = {}
|
||||
for id in pairs(game.data.trainers) do
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_" then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids) -- stable across runs
|
||||
for _, id in ipairs(ids) do
|
||||
local rec = game.data.trainers[id]
|
||||
for pi, party in ipairs((type(rec) == "table" and rec.parties) or {}) do
|
||||
local n, top = 0, 0
|
||||
for _, mon in ipairs(party) do
|
||||
n = n + 1
|
||||
top = math.max(top, tonumber(mon.level) or 0)
|
||||
end
|
||||
if n > 0 then
|
||||
local score = n * 100 + top
|
||||
if not best or score < best then
|
||||
best, class, partyIx = score, id, pi
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("fighting %s party %d (weakest of %d classes)")
|
||||
:format(tostring(class), partyIx, #ids))
|
||||
|
||||
local before = {}
|
||||
for i, m in ipairs(game.save.party) do
|
||||
before[i] = { exp = m.exp, level = m.level }
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newTrainer(game, class, partyIx)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(70)
|
||||
|
||||
-- through the intro to the menu, then FIGHT + first move, over and
|
||||
-- over until the fight resolves. The enemy's HP is logged as it goes,
|
||||
-- so a fight that stalls is visibly a stall rather than a silent zero.
|
||||
local lastHP, shotPanel = nil, false
|
||||
for i = 1, 900 do
|
||||
-- the fight ending does NOT end the loop until the card has been
|
||||
-- photographed: the last knockout resolves the battle in the same
|
||||
-- breath that queues the card, so breaking on `result` alone leaves
|
||||
-- every run with the card built and never seen
|
||||
if battle.result and shotPanel then break end
|
||||
-- hold the taps while the card is up, so it can be shot rather than
|
||||
-- dismissed on the next frame
|
||||
if (CARD_HOLD or 0) > 0 then
|
||||
CARD_HOLD = CARD_HOLD - 1
|
||||
if not shotPanel then
|
||||
local top = game.stack and game.stack:top()
|
||||
if top and rawget(top, "rows") then
|
||||
shotPanel = true
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
|
||||
.. "/exp_panel.png")
|
||||
U.log("shot the card")
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
elseif battle.phase == "menu" then
|
||||
battle.menuIndex = 1 -- FIGHT
|
||||
U.tap(game, "a")
|
||||
elseif battle.phase == "moveSelect" then
|
||||
battle.moveIndex = 1
|
||||
U.tap(game, "a")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
local hp = battle.enemy and battle.enemy.mon and battle.enemy.mon.hp
|
||||
if hp ~= lastHP then
|
||||
lastHP = hp
|
||||
U.log((" foe HP -> %s (phase %s, step %d)")
|
||||
:format(tostring(hp), tostring(battle.phase), i))
|
||||
end
|
||||
-- photograph the summary card the moment it is on top, then let the
|
||||
-- taps carry on and dismiss it
|
||||
local top = game.stack and game.stack:top()
|
||||
if top and not shotPanel and top ~= battle and rawget(top, "rows") then
|
||||
shotPanel = true
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
|
||||
.. "/exp_panel.png")
|
||||
U.log("shot the card")
|
||||
end
|
||||
U.wait(5)
|
||||
end
|
||||
U.log("battle result: " .. tostring(battle.result)
|
||||
.. " phase=" .. tostring(battle.phase))
|
||||
|
||||
for i, m in ipairs(game.save.party) do
|
||||
local was = before[i]
|
||||
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
|
||||
:format(game.data.pokemon[m.species].name, was.level, m.level,
|
||||
was.exp, m.exp, m.exp - was.exp))
|
||||
end
|
||||
U.log("done")
|
||||
end
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Driver: is a shiny visible on the FLAT paths -- the engine's own battle
|
||||
-- screen (3D-BTL OFF) and the cards rung (2D-3D A)?
|
||||
--
|
||||
-- DS_SHOTS=mods/DramaticShapeVoxelMod/.claude/shiny_update/flat \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_flat.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- The other shot driver covers the cards rung and the STADIUM rungs and NOT
|
||||
-- 3D-BTL OFF -- which is the rung a player who has never touched the mod's
|
||||
-- battle row is on, and so the one place a regression can sit unseen. It sat
|
||||
-- there: the pic tint was a multiply that could only darken, and the arrival
|
||||
-- sparkle was armed from Stadium.update and therefore never played here.
|
||||
--
|
||||
-- Each rung is shot TWICE, shiny and ordinary, from the same species at the
|
||||
-- same spot, as a STRIP -- the intro flashes the pic through palette variants
|
||||
-- on its way in, so a single frame lands wherever the pacing put it.
|
||||
--
|
||||
-- Reports, per rung: whether the roll landed, the PALETTE the pic was baked
|
||||
-- under (which is where the recolour now lives), and whether the flat-path
|
||||
-- sparkle armed and drew.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
|
||||
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 40
|
||||
local DIR = os.getenv("DS_SHOTS") or ".claude/shiny_update/flat"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then U.log("DRAMATIC_SHAPE is not loaded") return end
|
||||
local Shiny = lib.require("Shiny")
|
||||
local ShinyPics = lib.require("ShinyPics")
|
||||
local ShinyFlash = lib.require("ShinyFlash")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
|
||||
U.log(("wraps: pics=%s flash=%s"):format(
|
||||
tostring(PaletteFX.dramaticShapeShiny == true),
|
||||
tostring(ShinyFlash.installed == true)))
|
||||
|
||||
-- ------- what the palette wrap hands the image cache
|
||||
--
|
||||
-- The recolour is baked ONCE, at build time, so counting draws says nothing
|
||||
-- about it. What matters is the cache key and the colours behind it: ask
|
||||
-- PaletteFX the same two questions monPalette asks, with the note the
|
||||
-- sprite hook would have left a moment earlier.
|
||||
local function palReport(mon)
|
||||
ShinyPics.note({ kind = "battle", species = SPECIES, mon = mon,
|
||||
data = game.data })
|
||||
local cols = PaletteFX.monPal(game.data, SPECIES)
|
||||
local name = PaletteFX.monPalName(game.data, SPECIES)
|
||||
local out = { "pal=" .. tostring(name) }
|
||||
for i = 1, math.min(3, cols and #cols or 0) do
|
||||
local c = cols[i]
|
||||
if type(c) == "table" and c[1] then
|
||||
out[#out + 1] = ("c%d=%d,%d,%d"):format(i, c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- The party is built at ORDINARY odds and pinned afterwards, so the
|
||||
-- player's own Pikachu stays common: this run is about the foe.
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
local function leave()
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local function shoot(rung, label, shiny)
|
||||
OverworldBattle.setting:setValue(rung, game)
|
||||
Shiny.setOdds(shiny and 1 or 100000000)
|
||||
for k in pairs(ShinyFlash.debug) do ShinyFlash.debug[k] = 0 end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, SPECIES, LEVEL)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
local mon = battle.enemy and battle.enemy.mon
|
||||
U.log(("%s %s: 3D-BTL=%s isShiny=%s %s"):format(
|
||||
label, shiny and "shiny" or "normal",
|
||||
tostring(OverworldBattle.setting:get()), tostring(Shiny.isShiny(mon)),
|
||||
palReport(mon)))
|
||||
|
||||
-- The WIPE has to be walked through first. A driver run with no input at
|
||||
-- all sits on BattleTransition forever -- the battle is never pushed, so
|
||||
-- nothing about it draws and every counter below reads zero, which is
|
||||
-- exactly the false negative this probe produced before the taps went in.
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(10) end
|
||||
|
||||
-- the sparkle is three quarters of a second long and starts on the frame
|
||||
-- the pic appears, so the strip is TIGHT
|
||||
for k = 1, 10 do
|
||||
U.shot(game, ("%s/%s_%s_%02d.png"):format(DIR, label,
|
||||
shiny and "shiny" or "normal",
|
||||
k))
|
||||
U.wait(9)
|
||||
end
|
||||
local d = ShinyFlash.debug
|
||||
U.log((" flash: renders=%s armed=%d draws=%d sparks=%d follows=%d occ=%d %s")
|
||||
:format(tostring(d.renders), d.armed, d.draws, d.sparks,
|
||||
d.follows, d.occupied, tostring(d.err)))
|
||||
leave()
|
||||
end
|
||||
|
||||
shoot(false, "off", false)
|
||||
shoot(false, "off", true)
|
||||
shoot(true, "cards", false)
|
||||
shoot(true, "cards", true)
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Driver: five shiny encounters, five outdoor places, paced for a capture.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_obs.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Nothing here is a screenshot test -- it is a
|
||||
-- performance, timed for somebody recording the window.
|
||||
--
|
||||
-- Each beat: teleport, let the ground mesh, open a wild battle, let the
|
||||
-- Pokemon ARRIVE (the wipe, the send-out, the shiny sparkle), then hold
|
||||
-- three full seconds on it before closing the battle and moving on. The hold
|
||||
-- is deliberately silent -- no button taps during it -- so the recording is
|
||||
-- of the Pokemon standing there and not of a text box being clicked through.
|
||||
--
|
||||
-- Every encounter is shiny, because the odds are pinned to 1 for the run and
|
||||
-- the roll still happens where it always does, inside Pokemon.new.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- nothing to show")
|
||||
return
|
||||
end
|
||||
local Shiny = lib.require("Shiny")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
local StadiumInstall = lib.require("StadiumInstall")
|
||||
|
||||
-- ------- timing, in frames at 60fps
|
||||
local SETTLE = 110 -- after a teleport, for the neighbourhood to mesh
|
||||
local ARRIVE = 70 -- the wipe, then the Pokemon landing on its tile
|
||||
local HOLD = 180 -- THE THREE SECONDS
|
||||
local BETWEEN = 45 -- back on the map before the next one opens
|
||||
|
||||
-- ------- the models
|
||||
if not StadiumInstall.ready() then
|
||||
U.log("building stadium models first...")
|
||||
StadiumInstall.begin()
|
||||
local guard = 0
|
||||
while not StadiumInstall.ready() and guard < 2000 do
|
||||
for _ = 1, 6 do StadiumInstall.step() end
|
||||
U.wait(1)
|
||||
guard = guard + 1
|
||||
end
|
||||
end
|
||||
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
|
||||
|
||||
OverworldBattle.setting:setValue("stadium", game)
|
||||
|
||||
-- party FIRST, at ordinary odds: Pokemon.new is where shininess is
|
||||
-- decided, so pinning the odds before this made the player's own Pikachu
|
||||
-- shiny -- and that tints the player's side, which during the intro is the
|
||||
-- trainer sprite. The foe is what these runs are about.
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
Shiny.setOdds(1) -- from here on: every encounter
|
||||
|
||||
-- Five outdoor places, deliberately unalike -- a coast, an open route, a
|
||||
-- water city, a rocky pass and a wooded shore -- so the recording is five
|
||||
-- different-looking fights and not the same meadow five times. Each mon is
|
||||
-- put somewhere its colour has something to sit against.
|
||||
local RUNS = {
|
||||
{ "CHARIZARD", 50, "ROUTE_4", 10, 5 },
|
||||
{ "ELECTRODE", 40, "VIRIDIAN_CITY", 20, 20 },
|
||||
{ "VAPOREON", 42, "ROUTE_25", 12, 5 },
|
||||
{ "DRATINI", 30, "ROUTE_3", 10, 5 },
|
||||
}
|
||||
|
||||
-- a beat before the first one, so a recorder that started with the window
|
||||
-- is not already mid-encounter by the time it is rolling
|
||||
U.log("starting in 3...")
|
||||
U.wait(60)
|
||||
U.log("2...")
|
||||
U.wait(60)
|
||||
U.log("1...")
|
||||
U.wait(60)
|
||||
|
||||
for i, r in ipairs(RUNS) do
|
||||
local species, level, map, cx, cy = r[1], r[2], r[3], r[4], r[5]
|
||||
if not game.data.pokemon[species] then
|
||||
U.log(("skip %s -- not in this dataset"):format(species))
|
||||
elseif not game.data.maps[map] then
|
||||
U.log(("skip %s -- no map %s"):format(species, map))
|
||||
else
|
||||
U.teleport(game, map, cx, cy, "down")
|
||||
U.wait(SETTLE)
|
||||
|
||||
local battle = BattleState.newWild(game, species, level)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
local mon = battle.enemy and battle.enemy.mon
|
||||
U.log(("%d/4 %-10s %-14s shiny=%s")
|
||||
:format(i, species, map, tostring(Shiny.isShiny(mon))))
|
||||
|
||||
-- the arrival, then three seconds of nothing but the Pokemon
|
||||
U.wait(ARRIVE)
|
||||
U.wait(HOLD)
|
||||
|
||||
-- close the battle and go back to the map
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(BETWEEN)
|
||||
end
|
||||
end
|
||||
|
||||
Shiny.setOdds(8192)
|
||||
U.log("done -- five encounters")
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Driver: ONE shiny encounter, at real 1x speed, held open until you close
|
||||
-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh.
|
||||
--
|
||||
-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- ------- why the pacing is done here
|
||||
--
|
||||
-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns
|
||||
-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the
|
||||
-- machine will go and the loop takes one logic step (Game:update(1/60)) per
|
||||
-- turn of it. That is right for a screenshot script and wrong for a capture:
|
||||
-- the game runs at whatever multiple of real time the hardware manages, so
|
||||
-- "wait 180 frames" is three seconds only by coincidence.
|
||||
--
|
||||
-- The fix does not need an engine change. The loop is blocked while this
|
||||
-- coroutine is running, so sleeping HERE before each yield paces the whole
|
||||
-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by
|
||||
-- construction. Nothing else in the engine has to know.
|
||||
--
|
||||
-- ------- and why it never finishes
|
||||
--
|
||||
-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So
|
||||
-- holding the battle open forever is exactly how the window stays up until
|
||||
-- somebody closes it, which is the handshake this run wants: one window, one
|
||||
-- Pokemon, closed by hand, and only then the next.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
|
||||
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45
|
||||
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
|
||||
local CX = tonumber(os.getenv("DS_CX") or "") or 5
|
||||
local CY = tonumber(os.getenv("DS_CY") or "") or 8
|
||||
local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds
|
||||
|
||||
-- ------- real-time pacing
|
||||
--
|
||||
-- Each call is one logic step AND one sixtieth of a second of wall clock.
|
||||
-- The target is carried forward rather than measured from "now" so a slow
|
||||
-- frame is absorbed by the next one instead of compounding into drift.
|
||||
local nextAt = nil
|
||||
local function step()
|
||||
local now = love.timer.getTime()
|
||||
nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60)
|
||||
local slack = nextAt - now
|
||||
if slack > 0 then
|
||||
love.timer.sleep(slack)
|
||||
else
|
||||
nextAt = now -- fell behind; do not try to catch up
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
local function hold(seconds)
|
||||
for _ = 1, math.floor(seconds * 60) do step() end
|
||||
end
|
||||
|
||||
-- Unpaced, on purpose: a model rebuild is a loading screen, not part of
|
||||
-- the capture, and there is no reason to watch it at 1x.
|
||||
local function fast(n)
|
||||
for _ = 1, n do coroutine.yield() end
|
||||
end
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local Shiny = lib.require("Shiny")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
local StadiumInstall = lib.require("StadiumInstall")
|
||||
|
||||
if not StadiumInstall.ready() then
|
||||
U.log("building stadium models (not part of the capture)...")
|
||||
StadiumInstall.begin()
|
||||
local guard = 0
|
||||
while not StadiumInstall.ready() and guard < 2000 do
|
||||
for _ = 1, 6 do StadiumInstall.step() end
|
||||
fast(1)
|
||||
guard = guard + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- STADIUM A stages the fight on the MAP, which wants clear ground. A cave
|
||||
-- floor often has none, and the mode's answer there is STADIUM B: the two
|
||||
-- carried discs, which work anywhere. DS_RUNG picks between them per
|
||||
-- encounter rather than forcing one choice on every location.
|
||||
OverworldBattle.setting:setValue(os.getenv("DS_RUNG") or "stadium", game)
|
||||
|
||||
-- THE PARTY IS BUILT FIRST, at ordinary odds, and the roll is only pinned
|
||||
-- afterwards. Pokemon.new is where shininess is decided, so setting the
|
||||
-- odds before this line made the player's own Pikachu shiny too -- and a
|
||||
-- shiny on the player's side tints that side's pic, which during the intro
|
||||
-- is the TRAINER, so the player sprite came out discoloured for the whole
|
||||
-- send-out. The foe is the one this run is about.
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
Shiny.setOdds(1) -- from here on: the encounter
|
||||
|
||||
if not game.data.pokemon[SPECIES] then
|
||||
U.log("no such species: " .. SPECIES)
|
||||
return
|
||||
end
|
||||
if not game.data.maps[MAP] then
|
||||
U.log("no such map: " .. MAP)
|
||||
return
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, CX, CY, "down")
|
||||
hold(1.6) -- let the neighbourhood mesh
|
||||
|
||||
hold(LEAD) -- a beat before the fight opens
|
||||
|
||||
local battle = BattleState.newWild(game, SPECIES, LEVEL)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
-- ------- start the shot wider
|
||||
--
|
||||
-- BattleCam.zoom is a multiple of the rig's own frame height, so ABOVE one
|
||||
-- is zoomed OUT -- "the fight in its own landscape", per the note on the
|
||||
-- constants. 1.15 is fifteen percent wider, which happens to be exactly
|
||||
-- one notch of the player's own wheel (ZOOM_STEP).
|
||||
--
|
||||
-- Set AFTER the battle is pushed, because OverworldBattle's begin path
|
||||
-- calls BattleCam.reset() and that puts zoom and zoomGoal back to 1. Both
|
||||
-- are set, not just the goal: leaving the goal alone would make the shot
|
||||
-- glide outward over the first fifth of a second, and this wants to OPEN
|
||||
-- wide rather than pull back once the recording has started.
|
||||
local DS_ZOOM = tonumber(os.getenv("DS_ZOOM") or "") or 1.15
|
||||
local okCam, BattleCam = pcall(lib.require, "BattleCam")
|
||||
if okCam and BattleCam then
|
||||
BattleCam.zoom, BattleCam.zoomGoal = DS_ZOOM, DS_ZOOM
|
||||
end
|
||||
|
||||
local mon = battle.enemy and battle.enemy.mon
|
||||
U.log(("%s at %s -- shiny=%s zoom=%.2f (1x; close the window when done)")
|
||||
:format(SPECIES, MAP, tostring(Shiny.isShiny(mon)), DS_ZOOM))
|
||||
|
||||
-- The battle stays up, at 1x, until the window is closed by hand. No taps:
|
||||
-- the recording should be the Pokemon standing there, not a text box being
|
||||
-- clicked through.
|
||||
--
|
||||
-- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set
|
||||
-- it to a number of seconds and the run should take about that long by the
|
||||
-- wall clock, which is the only way to prove 1x is actually 1x.
|
||||
-- DS_SHOTS: capture the ARRIVAL as a strip, for checking that the sparkle
|
||||
-- fires on its own rather than only when a test arms it by hand. This is
|
||||
-- the same code path the capture run uses, which is the point -- the last
|
||||
-- bug here hid precisely because it was verified through a driver that
|
||||
-- armed the effect itself.
|
||||
local shots = os.getenv("DS_SHOTS")
|
||||
if shots then
|
||||
for k = 1, 26 do
|
||||
U.shot(game, ("%s/arrive_%02d.png"):format(shots, k))
|
||||
hold(0.15)
|
||||
end
|
||||
local ShinyFx = lib.require("ShinyFx")
|
||||
local d = ShinyFx.debug or {}
|
||||
U.log(("fx: armed=%s cleared=%s calls=%s noArena=%s noLive=%s quads=%s")
|
||||
:format(tostring(d.armed), tostring(d.cleared), tostring(d.calls),
|
||||
tostring(d.noArena), tostring(d.noLive), tostring(d.quads)))
|
||||
U.log("arrival strip written to " .. shots)
|
||||
return
|
||||
end
|
||||
|
||||
local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0
|
||||
if auto > 0 then
|
||||
local t0 = love.timer.getTime()
|
||||
hold(auto)
|
||||
U.log(("autoclose: asked for %.1fs, took %.2fs")
|
||||
:format(auto, love.timer.getTime() - t0))
|
||||
return
|
||||
end
|
||||
while true do step() end
|
||||
end
|
||||
@@ -0,0 +1,643 @@
|
||||
-- generated fixture: {dex, srcR,srcG,srcB, wantR,wantG,wantB}
|
||||
return {
|
||||
{6,222,131,123,160,165,185},
|
||||
{6,189,115,41,105,96,134},
|
||||
{6,16,16,8,12,11,13},
|
||||
{6,115,57,24,60,58,81},
|
||||
{6,230,139,57,131,122,165},
|
||||
{6,213,164,74,139,126,161},
|
||||
{6,41,32,24,31,30,35},
|
||||
{6,8,49,57,39,37,26},
|
||||
{6,82,41,8,39,36,54},
|
||||
{6,246,172,74,152,138,182},
|
||||
{6,172,98,32,92,84,120},
|
||||
{6,65,49,24,43,39,50},
|
||||
{6,213,131,123,157,161,179},
|
||||
{6,156,156,148,152,151,153},
|
||||
{6,255,246,156,209,193,218},
|
||||
{6,205,164,74,137,123,156},
|
||||
{6,24,90,90,65,61,49},
|
||||
{6,172,98,24,88,80,116},
|
||||
{6,123,24,24,61,68,86},
|
||||
{6,16,90,82,62,55,44},
|
||||
{6,230,148,65,137,127,168},
|
||||
{6,139,74,24,72,67,96},
|
||||
{6,156,90,41,89,84,113},
|
||||
{6,123,131,131,128,127,126},
|
||||
{6,246,164,90,157,148,188},
|
||||
{6,222,139,57,128,119,160},
|
||||
{6,98,57,16,52,47,67},
|
||||
{6,32,131,131,94,87,69},
|
||||
{6,148,90,32,82,76,105},
|
||||
{6,197,123,115,146,149,166},
|
||||
{6,222,106,106,150,157,178},
|
||||
{6,238,222,106,176,156,188},
|
||||
{6,32,41,32,38,35,36},
|
||||
{6,32,90,90,68,64,54},
|
||||
{6,32,49,49,43,41,38},
|
||||
{6,82,16,16,41,45,57},
|
||||
{6,189,189,189,189,189,189},
|
||||
{6,148,90,41,86,81,108},
|
||||
{6,8,16,24,17,18,14},
|
||||
{6,90,98,139,114,121,108},
|
||||
{42,123,41,115,16,15,26},
|
||||
{42,90,131,148,26,33,28},
|
||||
{42,90,156,189,27,42,32},
|
||||
{42,57,8,57,5,5,11},
|
||||
{42,123,106,197,30,44,46},
|
||||
{42,115,148,222,29,55,46},
|
||||
{42,98,57,98,17,17,22},
|
||||
{42,49,65,74,14,17,15},
|
||||
{42,197,189,205,46,50,53},
|
||||
{42,32,164,230,20,46,27},
|
||||
{42,41,205,246,19,52,24},
|
||||
{42,74,131,164,24,35,28},
|
||||
{42,90,98,189,27,42,40},
|
||||
{42,41,156,197,20,40,24},
|
||||
{42,98,90,180,27,40,40},
|
||||
{42,24,115,164,15,32,20},
|
||||
{42,164,189,205,39,53,44},
|
||||
{42,164,106,180,30,33,42},
|
||||
{42,74,148,213,25,47,34},
|
||||
{42,82,139,156,25,34,27},
|
||||
{42,90,180,230,25,55,34},
|
||||
{42,74,115,139,23,31,25},
|
||||
{42,49,189,230,21,49,26},
|
||||
{42,65,82,98,18,22,20},
|
||||
{42,172,115,189,31,35,45},
|
||||
{42,49,123,189,21,39,28},
|
||||
{42,172,197,197,42,50,42},
|
||||
{42,189,74,164,28,25,41},
|
||||
{42,65,57,123,18,26,27},
|
||||
{42,49,16,49,6,6,10},
|
||||
{42,32,57,74,11,16,12},
|
||||
{42,65,156,222,23,49,33},
|
||||
{42,106,98,189,29,43,43},
|
||||
{42,222,213,222,51,51,58},
|
||||
{42,172,197,205,41,53,44},
|
||||
{42,238,238,238,60,60,60},
|
||||
{42,189,197,205,46,53,49},
|
||||
{42,41,8,49,5,6,10},
|
||||
{42,49,0,49,3,3,9},
|
||||
{42,205,213,213,50,55,50},
|
||||
{79,139,82,57,28,18,31},
|
||||
{79,90,74,57,21,16,21},
|
||||
{79,246,106,74,48,17,63},
|
||||
{79,222,131,123,46,27,59},
|
||||
{79,156,131,82,36,24,34},
|
||||
{79,213,90,90,38,24,52},
|
||||
{79,222,197,148,62,31,57},
|
||||
{79,230,205,148,66,29,58},
|
||||
{79,213,189,156,58,34,56},
|
||||
{79,197,189,139,51,33,44},
|
||||
{79,180,106,90,37,26,42},
|
||||
{79,230,115,115,43,24,62},
|
||||
{79,148,82,82,29,24,34},
|
||||
{79,246,156,156,50,24,76},
|
||||
{79,24,8,8,4,3,5},
|
||||
{79,131,65,41,25,14,29},
|
||||
{79,213,98,82,40,23,51},
|
||||
{79,49,32,8,10,4,10},
|
||||
{79,123,74,49,25,16,27},
|
||||
{79,57,32,24,11,8,13},
|
||||
{79,180,57,65,28,20,39},
|
||||
{79,164,82,57,32,19,36},
|
||||
{79,148,106,82,32,24,34},
|
||||
{79,230,123,106,47,23,61},
|
||||
{79,238,115,98,47,21,63},
|
||||
{79,246,115,98,49,19,67},
|
||||
{79,238,139,131,49,24,68},
|
||||
{79,139,82,49,29,16,31},
|
||||
{79,246,131,106,53,20,68},
|
||||
{79,197,98,74,38,23,45},
|
||||
{79,213,106,98,41,25,53},
|
||||
{79,172,98,90,34,26,40},
|
||||
{79,197,98,82,38,24,46},
|
||||
{79,123,57,32,23,12,26},
|
||||
{79,189,189,180,48,44,46},
|
||||
{79,213,213,213,53,53,53},
|
||||
{79,115,106,82,27,22,26},
|
||||
{79,74,32,32,13,10,17},
|
||||
{79,148,74,57,28,19,33},
|
||||
{79,238,246,238,72,60,49},
|
||||
{146,172,131,0,231,152,197},
|
||||
{146,205,82,0,234,162,229},
|
||||
{146,57,57,156,183,215,205},
|
||||
{146,106,24,0,216,132,227},
|
||||
{146,24,24,32,162,177,172},
|
||||
{146,74,65,123,183,206,202},
|
||||
{146,213,172,82,230,199,219},
|
||||
{146,205,90,16,230,172,226},
|
||||
{146,246,197,8,235,179,209},
|
||||
{146,41,24,82,153,205,203},
|
||||
{146,255,230,115,245,212,229},
|
||||
{146,246,213,41,237,189,213},
|
||||
{146,74,90,131,188,208,196},
|
||||
{146,189,189,189,230,230,230},
|
||||
{146,115,74,0,227,135,199},
|
||||
{146,246,123,0,236,175,226},
|
||||
{146,197,131,8,231,165,210},
|
||||
{146,180,123,0,232,154,205},
|
||||
{146,106,115,65,204,182,186},
|
||||
{146,98,32,0,225,129,226},
|
||||
{146,255,180,0,237,177,215},
|
||||
{146,205,156,16,230,172,206},
|
||||
{146,197,156,74,225,196,215},
|
||||
{146,74,57,148,182,214,209},
|
||||
{146,255,123,0,237,177,228},
|
||||
{146,115,57,0,227,135,212},
|
||||
{146,246,255,222,253,245,245},
|
||||
{146,106,65,0,227,132,200},
|
||||
{146,98,16,0,210,129,226},
|
||||
{146,230,189,16,232,179,207},
|
||||
{146,255,230,41,240,190,212},
|
||||
{146,255,246,32,239,187,207},
|
||||
{146,213,156,41,227,187,214},
|
||||
{146,222,123,0,235,167,220},
|
||||
{146,164,98,0,231,150,209},
|
||||
{146,148,49,8,223,151,226},
|
||||
{146,90,65,0,225,127,187},
|
||||
{146,255,213,16,238,182,211},
|
||||
{146,24,32,49,156,191,168},
|
||||
{146,255,180,16,238,182,218},
|
||||
{96,131,82,57,175,159,190},
|
||||
{96,32,32,8,173,102,161},
|
||||
{96,197,156,0,213,138,216},
|
||||
{96,148,115,41,191,152,197},
|
||||
{96,57,57,41,160,144,158},
|
||||
{96,131,115,8,203,121,200},
|
||||
{96,65,57,16,180,115,180},
|
||||
{96,148,57,57,167,162,196},
|
||||
{96,131,32,49,142,142,195},
|
||||
{96,139,98,32,185,144,197},
|
||||
{96,123,82,49,175,152,189},
|
||||
{96,213,213,213,234,234,234},
|
||||
{96,139,90,65,179,165,192},
|
||||
{96,131,90,57,178,159,190},
|
||||
{96,164,148,98,200,185,201},
|
||||
{96,139,98,8,193,124,205},
|
||||
{96,106,98,49,183,150,182},
|
||||
{96,115,90,24,186,132,193},
|
||||
{96,115,98,32,188,139,190},
|
||||
{96,57,49,24,166,126,170},
|
||||
{96,172,131,8,204,135,210},
|
||||
{96,65,57,49,158,149,163},
|
||||
{96,106,98,82,178,169,180},
|
||||
{96,49,49,32,159,136,155},
|
||||
{96,180,139,8,206,138,211},
|
||||
{96,131,123,90,189,176,189},
|
||||
{96,156,148,123,201,193,201},
|
||||
{96,106,90,8,199,113,198},
|
||||
{96,148,115,24,195,140,201},
|
||||
{96,115,115,123,186,188,186},
|
||||
{96,106,65,57,164,155,181},
|
||||
{96,255,197,0,219,159,223},
|
||||
{96,230,222,205,238,233,239},
|
||||
{96,115,98,57,181,156,185},
|
||||
{96,180,172,131,211,199,211},
|
||||
{96,148,123,57,192,162,196},
|
||||
{96,230,180,0,216,150,220},
|
||||
{96,156,115,0,202,122,211},
|
||||
{96,172,123,32,195,152,205},
|
||||
{96,123,82,57,174,157,188},
|
||||
{140,49,49,49,101,101,101},
|
||||
{140,255,8,131,174,155,151},
|
||||
{140,139,82,32,135,138,118},
|
||||
{140,123,0,8,124,113,96},
|
||||
{140,230,24,24,169,162,149},
|
||||
{140,32,24,8,81,85,73},
|
||||
{140,230,156,156,212,210,205},
|
||||
{140,82,0,0,106,98,83},
|
||||
{140,131,82,24,128,132,111},
|
||||
{140,74,0,0,103,95,80},
|
||||
{140,222,172,172,214,212,209},
|
||||
{140,82,74,82,123,121,122},
|
||||
{140,197,106,106,182,179,173},
|
||||
{140,246,49,74,184,175,165},
|
||||
{140,255,16,123,177,159,154},
|
||||
{140,156,24,57,143,129,120},
|
||||
{140,156,82,8,135,139,111},
|
||||
{140,255,131,156,214,208,203},
|
||||
{140,222,213,205,224,225,223},
|
||||
{140,82,49,16,106,109,92},
|
||||
{140,205,172,172,207,206,204},
|
||||
{140,230,172,172,217,215,212},
|
||||
{140,230,82,115,188,180,174},
|
||||
{140,255,82,189,198,183,182},
|
||||
{140,222,156,90,185,187,175},
|
||||
{140,180,98,16,145,150,125},
|
||||
{140,238,115,123,202,197,190},
|
||||
{140,65,8,16,100,92,82},
|
||||
{140,172,74,82,161,157,151},
|
||||
{140,255,172,197,228,223,220},
|
||||
{140,197,57,65,166,160,152},
|
||||
{140,205,131,49,164,166,152},
|
||||
{140,57,57,57,106,106,106},
|
||||
{140,131,32,49,134,125,115},
|
||||
{140,57,32,32,101,98,94},
|
||||
{140,123,8,41,125,110,100},
|
||||
{140,246,32,82,178,167,158},
|
||||
{140,164,98,32,144,147,127},
|
||||
{140,255,0,90,171,155,147},
|
||||
{140,255,106,172,206,195,192},
|
||||
{23,139,106,16,46,41,75},
|
||||
{23,172,131,24,58,53,94},
|
||||
{23,106,8,24,29,50,57},
|
||||
{23,246,197,49,85,73,149},
|
||||
{23,255,213,41,88,69,153},
|
||||
{23,238,180,16,74,64,126},
|
||||
{23,222,123,131,101,139,158},
|
||||
{23,246,213,82,99,81,165},
|
||||
{23,156,106,172,93,115,97},
|
||||
{23,90,16,24,29,44,50},
|
||||
{23,164,90,98,85,99,106},
|
||||
{23,213,164,41,77,71,119},
|
||||
{23,115,74,98,65,77,75},
|
||||
{23,230,189,106,98,92,160},
|
||||
{23,115,65,123,62,79,67},
|
||||
{23,222,172,74,87,82,140},
|
||||
{23,213,172,90,93,88,139},
|
||||
{23,255,213,57,91,73,161},
|
||||
{23,156,115,164,96,113,101},
|
||||
{23,222,156,8,63,56,116},
|
||||
{23,123,82,123,71,83,76},
|
||||
{23,98,49,115,52,71,55},
|
||||
{23,106,74,65,58,63,70},
|
||||
{23,180,180,180,135,135,135},
|
||||
{23,115,49,90,52,71,67},
|
||||
{23,98,57,90,52,64,59},
|
||||
{23,180,123,180,102,125,112},
|
||||
{23,90,65,41,42,44,56},
|
||||
{23,238,172,24,73,66,130},
|
||||
{23,222,164,8,65,56,116},
|
||||
{23,131,106,41,55,52,77},
|
||||
{23,180,139,164,110,129,126},
|
||||
{23,32,8,24,12,18,17},
|
||||
{23,213,164,24,71,62,115},
|
||||
{23,131,74,148,73,94,77},
|
||||
{23,65,0,16,15,30,34},
|
||||
{23,139,106,49,59,58,83},
|
||||
{23,189,139,24,62,57,103},
|
||||
{23,131,65,74,64,78,83},
|
||||
{23,65,49,0,18,15,34},
|
||||
{114,205,197,197,197,197,205},
|
||||
{114,180,180,189,180,189,180},
|
||||
{114,123,123,230,123,230,123},
|
||||
{114,205,205,255,205,255,205},
|
||||
{114,189,49,74,49,74,189},
|
||||
{114,222,213,222,213,222,222},
|
||||
{114,82,82,205,82,205,82},
|
||||
{114,131,131,238,131,238,131},
|
||||
{114,82,82,164,82,164,82},
|
||||
{114,230,230,255,230,255,230},
|
||||
{114,41,41,32,41,32,41},
|
||||
{114,148,32,57,32,57,148},
|
||||
{114,65,65,90,65,90,65},
|
||||
{114,172,172,180,172,180,172},
|
||||
{114,123,123,213,123,213,123},
|
||||
{114,238,230,238,230,238,238},
|
||||
{114,41,41,49,41,49,41},
|
||||
{114,238,238,238,238,238,238},
|
||||
{114,205,197,205,197,205,205},
|
||||
{114,230,230,222,230,222,230},
|
||||
{114,90,90,90,90,90,90},
|
||||
{114,255,255,238,255,238,255},
|
||||
{114,106,106,238,106,238,106},
|
||||
{114,246,106,131,106,131,246},
|
||||
{114,49,49,123,49,123,49},
|
||||
{114,16,16,41,16,41,16},
|
||||
{114,255,255,246,255,246,255},
|
||||
{114,8,8,16,8,16,8},
|
||||
{114,205,82,106,82,106,205},
|
||||
{114,205,205,213,205,213,205},
|
||||
{114,32,32,24,32,24,32},
|
||||
{114,41,49,106,49,106,41},
|
||||
{114,41,41,98,41,98,41},
|
||||
{114,123,123,148,123,148,123},
|
||||
{114,156,148,205,148,205,156},
|
||||
{114,156,41,57,41,57,156},
|
||||
{114,222,90,106,90,106,222},
|
||||
{114,57,57,65,57,65,57},
|
||||
{114,123,123,205,123,205,123},
|
||||
{114,82,74,82,74,82,82},
|
||||
{134,197,205,213,212,197,213},
|
||||
{134,205,222,230,224,205,230},
|
||||
{134,213,222,238,238,213,236},
|
||||
{134,32,90,90,58,32,90},
|
||||
{134,164,189,189,175,164,189},
|
||||
{134,65,90,131,131,65,126},
|
||||
{134,32,139,172,128,32,172},
|
||||
{134,156,172,189,188,156,189},
|
||||
{134,255,246,148,148,255,187},
|
||||
{134,0,8,16,15,0,16},
|
||||
{134,24,41,82,82,24,73},
|
||||
{134,57,148,180,144,57,180},
|
||||
{134,123,197,197,156,123,197},
|
||||
{134,148,197,205,182,148,205},
|
||||
{134,24,8,0,5,24,0},
|
||||
{134,65,90,148,148,65,136},
|
||||
{134,164,213,222,199,164,222},
|
||||
{134,41,74,123,123,41,119},
|
||||
{134,8,16,65,65,8,47},
|
||||
{134,213,222,156,156,222,195},
|
||||
{134,74,57,32,32,74,34},
|
||||
{134,65,106,148,144,65,148},
|
||||
{134,24,41,98,98,24,82},
|
||||
{134,115,123,123,119,115,123},
|
||||
{134,49,90,98,79,49,98},
|
||||
{134,131,172,197,186,131,197},
|
||||
{134,0,24,16,3,0,24},
|
||||
{134,32,82,98,78,32,98},
|
||||
{134,156,180,205,203,156,205},
|
||||
{134,148,213,222,190,148,222},
|
||||
{134,139,197,205,177,139,205},
|
||||
{134,32,49,98,98,32,85},
|
||||
{134,82,98,148,148,82,134},
|
||||
{134,41,49,115,115,41,90},
|
||||
{134,222,230,230,226,222,230},
|
||||
{134,106,106,115,115,106,111},
|
||||
{134,32,57,106,106,32,98},
|
||||
{134,32,106,148,126,32,148},
|
||||
{134,32,90,131,118,32,131},
|
||||
{134,41,90,139,134,41,139},
|
||||
{143,82,106,90,105,123,120},
|
||||
{143,148,115,98,154,149,125},
|
||||
{143,230,222,213,230,230,221},
|
||||
{143,139,65,65,149,121,94},
|
||||
{143,16,0,8,63,15,15},
|
||||
{143,213,180,123,197,203,154},
|
||||
{143,189,90,90,181,154,127},
|
||||
{143,115,57,41,130,113,70},
|
||||
{143,57,57,57,82,82,82},
|
||||
{143,172,139,90,165,169,124},
|
||||
{143,189,156,123,186,186,150},
|
||||
{143,222,222,222,226,226,226},
|
||||
{143,255,230,197,243,245,214},
|
||||
{143,230,230,230,233,233,233},
|
||||
{143,197,197,180,197,201,192},
|
||||
{143,222,205,123,192,210,156},
|
||||
{143,238,230,180,219,231,199},
|
||||
{143,90,90,65,100,110,90},
|
||||
{143,213,197,123,188,203,154},
|
||||
{143,205,197,123,180,198,153},
|
||||
{143,90,82,57,103,110,83},
|
||||
{143,205,180,139,196,200,164},
|
||||
{143,213,213,189,208,214,201},
|
||||
{143,115,65,65,130,111,91},
|
||||
{143,164,131,74,157,164,108},
|
||||
{143,213,197,156,202,209,178},
|
||||
{143,123,106,65,127,136,92},
|
||||
{143,180,148,148,184,175,167},
|
||||
{143,205,197,156,194,203,176},
|
||||
{143,123,82,74,136,124,100},
|
||||
{143,24,8,8,60,46,32},
|
||||
{143,255,238,189,235,244,208},
|
||||
{143,230,222,205,227,229,215},
|
||||
{143,213,180,131,200,205,160},
|
||||
{143,131,65,65,142,118,93},
|
||||
{143,222,189,139,208,213,167},
|
||||
{143,24,16,8,60,60,32},
|
||||
{143,205,189,156,199,203,176},
|
||||
{143,156,148,98,147,159,127},
|
||||
{143,164,131,98,165,165,128},
|
||||
{95,57,49,65,34,41,31},
|
||||
{95,0,197,230,144,42,0},
|
||||
{95,197,197,238,181,201,71},
|
||||
{95,0,16,65,41,37,0},
|
||||
{95,148,156,156,99,92,91},
|
||||
{95,238,238,230,132,126,167},
|
||||
{95,98,98,98,61,61,61},
|
||||
{95,148,148,156,98,99,91},
|
||||
{95,16,32,156,94,98,10},
|
||||
{95,0,49,106,66,46,0},
|
||||
{95,0,123,197,123,65,0},
|
||||
{95,0,180,238,149,59,0},
|
||||
{95,0,8,16,10,7,0},
|
||||
{95,139,148,164,106,101,83},
|
||||
{95,65,74,82,51,47,41},
|
||||
{95,189,197,197,128,115,113},
|
||||
{95,0,90,197,123,85,0},
|
||||
{95,106,115,131,82,79,66},
|
||||
{95,0,139,205,128,60,0},
|
||||
{95,131,123,148,85,94,76},
|
||||
{95,0,24,49,31,20,0},
|
||||
{95,0,8,98,57,61,0},
|
||||
{95,98,98,115,70,72,61},
|
||||
{95,139,139,139,87,87,87},
|
||||
{95,49,49,57,35,36,31},
|
||||
{95,0,74,131,82,48,0},
|
||||
{95,115,106,131,74,82,66},
|
||||
{95,0,115,197,123,70,0},
|
||||
{95,156,148,180,106,121,84},
|
||||
{95,123,123,139,86,87,77},
|
||||
{95,0,41,156,98,86,0},
|
||||
{95,0,106,172,108,57,0},
|
||||
{95,16,57,156,98,85,10},
|
||||
{95,0,115,148,93,35,0},
|
||||
{95,205,205,230,164,176,96},
|
||||
{95,131,139,156,100,96,80},
|
||||
{95,189,197,205,140,128,106},
|
||||
{95,98,106,131,82,80,61},
|
||||
{95,213,213,246,194,216,71},
|
||||
{95,24,32,32,20,16,15},
|
||||
{35,180,74,65,180,65,123},
|
||||
{35,197,82,82,197,82,140},
|
||||
{35,172,131,131,172,131,152},
|
||||
{35,65,41,41,65,41,53},
|
||||
{35,213,230,230,213,230,230},
|
||||
{35,172,74,65,172,65,119},
|
||||
{35,148,164,164,148,164,164},
|
||||
{35,148,106,98,148,98,123},
|
||||
{35,90,65,57,90,57,74},
|
||||
{35,57,57,156,57,57,156},
|
||||
{35,222,164,164,222,164,193},
|
||||
{35,255,222,222,255,222,238},
|
||||
{35,8,8,0,1,8,0},
|
||||
{35,148,148,156,148,148,156},
|
||||
{35,49,41,16,15,52,10},
|
||||
{35,222,246,238,222,246,238},
|
||||
{35,148,82,90,148,82,115},
|
||||
{35,57,8,8,57,8,33},
|
||||
{35,131,131,131,131,131,131},
|
||||
{35,255,189,197,255,189,222},
|
||||
{35,205,90,82,205,82,144},
|
||||
{35,230,197,197,230,197,214},
|
||||
{35,238,139,139,238,139,189},
|
||||
{35,180,115,115,180,115,148},
|
||||
{35,65,57,57,65,57,57},
|
||||
{35,172,139,131,172,131,152},
|
||||
{35,131,32,24,131,24,78},
|
||||
{35,65,49,8,9,69,0},
|
||||
{35,41,24,8,8,44,2},
|
||||
{35,180,139,123,180,123,152},
|
||||
{35,98,123,123,98,123,123},
|
||||
{35,41,41,115,41,41,115},
|
||||
{35,106,16,16,106,16,61},
|
||||
{35,106,131,131,106,131,131},
|
||||
{35,16,16,24,16,16,24},
|
||||
{35,156,139,139,156,139,139},
|
||||
{35,123,32,24,123,24,74},
|
||||
{35,49,24,24,49,24,37},
|
||||
{35,16,16,41,16,16,41},
|
||||
{35,189,164,172,189,164,176},
|
||||
{36,230,148,172,230,148,189},
|
||||
{36,255,148,164,255,148,202},
|
||||
{36,156,123,123,156,123,123},
|
||||
{36,189,156,148,189,148,169},
|
||||
{36,98,41,24,98,24,61},
|
||||
{36,74,0,0,74,0,37},
|
||||
{36,148,82,74,148,74,111},
|
||||
{36,65,49,49,65,49,49},
|
||||
{36,238,197,180,238,180,209},
|
||||
{36,230,180,172,230,172,201},
|
||||
{36,24,16,16,24,16,20},
|
||||
{36,246,148,164,246,148,197},
|
||||
{36,180,156,156,180,156,156},
|
||||
{36,49,49,49,49,49,49},
|
||||
{36,222,189,189,222,189,206},
|
||||
{36,98,90,90,98,90,90},
|
||||
{36,148,131,123,148,131,123},
|
||||
{36,222,222,222,222,222,222},
|
||||
{36,222,189,180,222,180,201},
|
||||
{36,213,156,156,213,156,185},
|
||||
{36,222,180,180,222,180,201},
|
||||
{36,197,123,115,197,115,156},
|
||||
{36,230,139,156,230,139,185},
|
||||
{36,115,90,90,115,90,90},
|
||||
{36,32,8,8,32,8,20},
|
||||
{36,65,49,41,65,41,53},
|
||||
{36,8,0,0,8,0,4},
|
||||
{36,98,32,32,98,32,65},
|
||||
{36,164,98,98,164,98,131},
|
||||
{36,106,98,90,106,98,90},
|
||||
{36,230,205,197,230,197,214},
|
||||
{36,246,213,205,246,205,226},
|
||||
{36,164,148,148,164,148,148},
|
||||
{36,238,164,189,238,164,201},
|
||||
{36,156,131,123,156,131,123},
|
||||
{36,230,172,180,230,172,201},
|
||||
{36,24,8,8,24,8,16},
|
||||
{36,106,90,90,106,90,90},
|
||||
{36,213,197,189,213,189,201},
|
||||
{36,246,172,156,246,156,201},
|
||||
{39,255,189,197,250,194,250},
|
||||
{39,222,255,255,240,255,222},
|
||||
{39,139,131,139,139,131,139},
|
||||
{39,74,148,139,115,154,68},
|
||||
{39,222,246,246,235,248,220},
|
||||
{39,131,156,148,131,156,148},
|
||||
{39,41,82,82,64,85,38},
|
||||
{39,189,90,98,182,97,182},
|
||||
{39,180,139,148,177,142,177},
|
||||
{39,205,123,131,199,129,199},
|
||||
{39,180,148,156,178,150,178},
|
||||
{39,74,164,148,124,171,67},
|
||||
{39,230,230,238,234,239,229},
|
||||
{39,106,197,189,157,204,99},
|
||||
{39,74,90,90,74,90,90},
|
||||
{39,230,139,148,223,146,223},
|
||||
{39,41,98,82,73,102,37},
|
||||
{39,246,156,164,239,163,239},
|
||||
{39,180,197,197,180,197,197},
|
||||
{39,139,90,106,135,94,135},
|
||||
{39,32,74,74,55,77,29},
|
||||
{39,131,82,90,127,86,127},
|
||||
{39,32,106,90,73,112,26},
|
||||
{39,164,189,189,178,191,162},
|
||||
{39,41,115,98,82,121,35},
|
||||
{39,246,172,172,240,178,240},
|
||||
{39,131,205,197,172,211,125},
|
||||
{39,197,106,106,190,113,190},
|
||||
{39,98,65,74,96,67,96},
|
||||
{39,205,246,246,228,249,202},
|
||||
{39,98,172,156,139,178,92},
|
||||
{39,106,180,172,147,186,100},
|
||||
{39,24,57,57,42,59,22},
|
||||
{39,172,197,197,186,199,170},
|
||||
{39,115,139,148,115,139,148},
|
||||
{39,32,106,98,73,112,26},
|
||||
{39,230,180,197,226,184,226},
|
||||
{39,57,90,82,75,92,55},
|
||||
{39,74,148,131,115,154,68},
|
||||
{39,189,238,238,216,242,185},
|
||||
{40,164,123,131,161,126,161},
|
||||
{40,139,131,131,139,131,131},
|
||||
{40,156,139,139,156,139,139},
|
||||
{40,222,164,180,218,168,218},
|
||||
{40,222,205,205,221,206,221},
|
||||
{40,255,238,246,254,239,254},
|
||||
{40,230,164,164,225,169,225},
|
||||
{40,131,131,131,131,131,131},
|
||||
{40,197,115,123,191,121,191},
|
||||
{40,82,106,106,82,106,106},
|
||||
{40,230,172,189,226,176,226},
|
||||
{40,148,156,164,148,156,164},
|
||||
{40,74,90,90,74,90,90},
|
||||
{40,82,156,139,123,162,76},
|
||||
{40,164,115,131,160,119,160},
|
||||
{40,82,49,65,80,51,80},
|
||||
{40,65,106,106,88,109,62},
|
||||
{40,74,98,98,74,98,98},
|
||||
{40,189,106,106,183,112,183},
|
||||
{40,222,246,246,235,248,220},
|
||||
{40,222,197,205,220,199,220},
|
||||
{40,115,139,148,115,139,148},
|
||||
{40,238,246,246,242,247,237},
|
||||
{40,74,131,123,106,135,70},
|
||||
{40,156,115,131,153,118,153},
|
||||
{40,82,115,115,100,117,80},
|
||||
{40,230,148,148,224,154,224},
|
||||
{40,148,213,205,184,218,143},
|
||||
{40,246,189,197,242,193,242},
|
||||
{40,139,156,164,139,156,164},
|
||||
{40,172,189,197,186,199,170},
|
||||
{40,230,172,180,226,176,226},
|
||||
{40,164,131,139,162,133,162},
|
||||
{40,205,148,156,201,152,201},
|
||||
{40,238,246,238,238,246,238},
|
||||
{40,156,115,123,153,118,153},
|
||||
{40,156,106,123,152,110,152},
|
||||
{40,197,131,131,192,136,192},
|
||||
{40,255,164,164,248,171,248},
|
||||
{40,131,172,164,154,175,128},
|
||||
{130,123,197,230,220,80,70},
|
||||
{130,230,222,180,230,222,180},
|
||||
{130,255,222,131,255,222,131},
|
||||
{130,57,131,180,148,53,47},
|
||||
{130,106,82,41,106,82,41},
|
||||
{130,98,180,238,232,56,43},
|
||||
{130,65,90,98,80,55,53},
|
||||
{130,189,172,123,189,172,123},
|
||||
{130,65,123,148,121,58,53},
|
||||
{130,221,221,221,221,221,221},
|
||||
{130,57,82,106,87,49,47},
|
||||
{130,222,222,222,222,222,222},
|
||||
{130,189,164,106,189,164,106},
|
||||
{130,85,85,85,85,85,85},
|
||||
{130,172,148,98,172,148,98},
|
||||
{130,115,189,230,221,73,62},
|
||||
{130,189,164,74,189,164,74},
|
||||
{130,230,246,246,223,171,167},
|
||||
{130,205,180,90,205,180,90},
|
||||
{130,82,57,16,82,57,16},
|
||||
{130,238,90,106,238,90,106},
|
||||
{130,156,180,164,156,180,164},
|
||||
{130,82,98,106,82,98,106},
|
||||
{130,164,230,246,239,106,97},
|
||||
{130,133,133,133,133,133,133},
|
||||
{130,156,131,57,156,131,57},
|
||||
{130,90,49,106,87,43,40},
|
||||
{130,98,115,98,98,115,98},
|
||||
{130,98,98,65,98,98,65},
|
||||
{130,148,49,65,148,49,65},
|
||||
{130,156,156,156,156,156,156},
|
||||
{130,49,49,82,67,42,40},
|
||||
{130,230,222,230,230,222,230},
|
||||
{130,153,153,153,153,153,153},
|
||||
{130,255,230,255,255,150,143},
|
||||
{130,8,82,131,107,13,7},
|
||||
{130,139,213,255,255,81,68},
|
||||
{130,32,16,32,26,14,13},
|
||||
{130,164,57,65,164,57,65},
|
||||
{130,82,82,32,82,82,32},
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shiny encounters, one per outdoor place, one window at a time.
|
||||
#
|
||||
# bash mods/DramaticShapeVoxelMod/tests/shiny_run.sh
|
||||
#
|
||||
# Run from the PROJECT ROOT.
|
||||
#
|
||||
# Each Pokemon gets its OWN game window, launched only after the previous one
|
||||
# has been closed -- the loop blocks on the process, so closing the window is
|
||||
# what advances it. Nothing is on a timer, so a take can be as long as it
|
||||
# needs to be, and a bad one can just be closed and re-run.
|
||||
#
|
||||
# The battles run at true 1x: tests/shiny_one.lua paces itself against the
|
||||
# wall clock, because a POKEPORT_DRIVER run is otherwise unpaced and goes as
|
||||
# fast as the machine can manage.
|
||||
set -u
|
||||
|
||||
LOVE=${LOVE:-/c/Program Files/LOVE/lovec.exe}
|
||||
DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua
|
||||
|
||||
# species | level | map | cell x | cell y | rung
|
||||
#
|
||||
# Lapras out on the open sea: ROUTE_20 is the 50-wide water run between
|
||||
# Fuchsia and Cinnabar, and the middle of it is nothing but water in every
|
||||
# direction. A species or map this dataset does not have is skipped with a
|
||||
# line rather than failing the run.
|
||||
#
|
||||
# The rung column picks STADIUM A (the fight staged on the map itself) or
|
||||
# STADIUM B (the two carried discs). Over open water there is no clear GROUND
|
||||
# to stage on, which is exactly the case B exists for -- see shiny_one.lua.
|
||||
RUNS=(
|
||||
"LAPRAS|40|ROUTE_20|25|4|stadium"
|
||||
)
|
||||
|
||||
OPTS="$APPDATA/LOVE/pokemon-love2d/options.lua"
|
||||
if [ -f "$OPTS" ]; then
|
||||
cp "$OPTS" "$OPTS.shiny_run_backup"
|
||||
echo "backed up options.lua"
|
||||
fi
|
||||
|
||||
i=0
|
||||
for row in "${RUNS[@]}"; do
|
||||
i=$((i + 1))
|
||||
IFS='|' read -r SPECIES LEVEL MAP CX CY RUNG <<< "$row"
|
||||
echo ""
|
||||
echo "=== $i/${#RUNS[@]} $SPECIES at $MAP ==="
|
||||
echo " close the window when you are done recording it"
|
||||
DS_SPECIES="$SPECIES" DS_LEVEL="$LEVEL" DS_MAP="$MAP" \
|
||||
DS_CX="$CX" DS_CY="$CY" DS_RUNG="${RUNG:-stadium}" \
|
||||
POKEPORT_DRIVER="$DRIVER" "$LOVE" .
|
||||
done
|
||||
|
||||
# The game persists the whole options table mid-run (OverworldBattle.forceOG),
|
||||
# so a run leaves the display settings it used on disk. Put them back.
|
||||
if [ -f "$OPTS.shiny_run_backup" ]; then
|
||||
cp "$OPTS.shiny_run_backup" "$OPTS"
|
||||
echo ""
|
||||
echo "options.lua restored"
|
||||
fi
|
||||
|
||||
echo "done -- ${#RUNS[@]} encounters"
|
||||
@@ -0,0 +1,251 @@
|
||||
-- Driver: photograph the shiny system, each case beside its own control.
|
||||
--
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/shiny_update \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_shots.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- SHOT_DIR must already exist -- the capture writes with io.open.
|
||||
--
|
||||
-- EVERY CASE IS SHOT TWICE, the same species on the same tile, once
|
||||
-- ordinary and once shiny. A single shiny screenshot proves nothing: these
|
||||
-- are N64 models under a day/night tint on generated ground, and "that
|
||||
-- looks a bit purple" is not evidence. The pair is.
|
||||
--
|
||||
-- The four species are chosen to exercise the four things that can break:
|
||||
--
|
||||
-- GYARADOS one of the five Stadium gives a REAL alternate texture, so
|
||||
-- it runs the explicit colour table rather than the HSL slide.
|
||||
-- Blue to red, the least deniable shiny in Gen 1.
|
||||
-- CHARIZARD the biggest slide in the set (H -136, S -6). Also the one
|
||||
-- whose canonical shiny is famously black, which the Stadium
|
||||
-- values do NOT reproduce -- they give a dusky slate-violet.
|
||||
-- Shot precisely so that difference is on the record.
|
||||
-- GOLBAT L -6, the darkest lightness step there is. If the guard
|
||||
-- rails are wrong this is where it goes to mud.
|
||||
-- PONYTA made of fire. Its flames are GENERATED frames, excluded
|
||||
-- from the recolour, so the correct result is a recoloured
|
||||
-- body with an ordinary mane. That exclusion is invisible in
|
||||
-- every other species.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR")
|
||||
or "mods/DramaticShapeVoxelMod/.claude/shiny_update"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- nothing to shoot")
|
||||
return
|
||||
end
|
||||
local Shiny = lib.require("Shiny")
|
||||
local ShinyFx = lib.require("ShinyFx")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
local StadiumInstall = lib.require("StadiumInstall")
|
||||
local StadiumPack = lib.require("StadiumPack")
|
||||
|
||||
-- ------- the models
|
||||
--
|
||||
-- REV went to 3 with the shiny variants, so an existing cache is stale and
|
||||
-- the game rebuilds all 151 on the loading screen. That is a minute or so
|
||||
-- of frames, and it has to be waited out rather than assumed: shooting
|
||||
-- before it lands gets flat 2D pics and a very confusing set of images.
|
||||
-- Driven from HERE rather than waited on. The build is normally pumped by
|
||||
-- the loading screen StadiumScreen.maybePush puts up on the first world
|
||||
-- frame, but a driver owns the frame loop and that screen never came up --
|
||||
-- a first run sat at "idle 0/151" for twelve thousand frames and shot four
|
||||
-- unrecoloured Pokemon. Calling begin/step directly is both faster and
|
||||
-- honest about what is being tested, which is the extraction, not the
|
||||
-- screen that usually triggers it.
|
||||
-- the upgrade question, asked before anything is built: with a stale
|
||||
-- marker on disk, does this machine know it has work to do?
|
||||
U.log(("upgrade check: ready=%s usable=%s available=%s pending=%s rom=%s")
|
||||
:format(tostring(StadiumInstall.ready()),
|
||||
tostring(StadiumInstall.usable()),
|
||||
tostring(StadiumInstall.available()),
|
||||
tostring(StadiumInstall.pending()),
|
||||
tostring(StadiumInstall.romPresent())))
|
||||
|
||||
if not StadiumInstall.ready() then
|
||||
local ok, err = StadiumInstall.begin()
|
||||
U.log(("stadium build: begin=%s %s"):format(tostring(ok), tostring(err or "")))
|
||||
local guard = 0
|
||||
while not StadiumInstall.ready() and guard < 2000 do
|
||||
-- several species per frame: 151 of them at one a frame is a long
|
||||
-- wait for no reason, and nothing here needs to be drawn
|
||||
for _ = 1, 6 do StadiumInstall.step() end
|
||||
U.wait(1)
|
||||
guard = guard + 1
|
||||
local st = StadiumInstall.status
|
||||
if st and st.state == "failed" then
|
||||
U.log("stadium build FAILED: " .. tostring(st.error))
|
||||
break
|
||||
end
|
||||
if guard % 10 == 0 then
|
||||
U.log((" building: %s %d/%d"):format(tostring(st and st.state),
|
||||
(st and st.done) or 0, (st and st.total) or 0))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
|
||||
|
||||
-- and prove the shiny packs are actually THERE before shooting anything
|
||||
for _, dex in ipairs({ 130, 6, 42, 77 }) do
|
||||
U.log((" pack %03d: normal=%s shiny=%s"):format(
|
||||
dex, tostring(StadiumPack.available(dex, false)),
|
||||
tostring(StadiumPack.available(dex, true))))
|
||||
end
|
||||
|
||||
-- STADIUM A: models, staged on the map
|
||||
OverworldBattle.setting:setValue("stadium", game)
|
||||
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()))
|
||||
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
local CASES = {
|
||||
{ "GYARADOS", 40, "ROUTE_1", 5, 8 },
|
||||
{ "CHARIZARD", 50, "ROUTE_1", 5, 8 },
|
||||
{ "GOLBAT", 40, "ROUTE_1", 5, 8 },
|
||||
{ "PONYTA", 40, "ROUTE_1", 5, 8 },
|
||||
}
|
||||
|
||||
-- Odds are the real lever, so the shiny half goes through the SAME path a
|
||||
-- player's encounter does -- decided inside Pokemon.new by a roll -- rather
|
||||
-- than being stamped on afterwards. 1 means every mon; a huge denominator
|
||||
-- means none, which is what makes the control a control.
|
||||
local function setOdds(shiny)
|
||||
Shiny.setOdds(shiny and 1 or 100000000)
|
||||
end
|
||||
|
||||
local function toMenu()
|
||||
for _ = 1, 16 do U.tap(game, "a") U.wait(8) end
|
||||
end
|
||||
|
||||
local function leave()
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
for i, c in ipairs(CASES) do
|
||||
local species, level, map, cx, cy = c[1], c[2], c[3], c[4], c[5]
|
||||
if not game.data.pokemon[species] then
|
||||
U.log("no such species in this dataset: " .. species)
|
||||
else
|
||||
for _, variant in ipairs({ "normal", "shiny" }) do
|
||||
local shiny = (variant == "shiny")
|
||||
setOdds(shiny)
|
||||
|
||||
U.teleport(game, map, cx, cy, "down")
|
||||
U.wait(90) -- let the neighbourhood's meshes land
|
||||
|
||||
local battle = BattleState.newWild(game, species, level)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
local mon = battle.enemy and battle.enemy.mon
|
||||
U.log(("%s %s: isShiny=%s dvs=%s/%s/%s/%s flag=%s"):format(
|
||||
species, variant, tostring(Shiny.isShiny(mon)),
|
||||
tostring(mon and mon.dvs and mon.dvs.attack),
|
||||
tostring(mon and mon.dvs and mon.dvs.defense),
|
||||
tostring(mon and mon.dvs and mon.dvs.speed),
|
||||
tostring(mon and mon.dvs and mon.dvs.special),
|
||||
tostring(mon and mon.shiny)))
|
||||
|
||||
-- THE ARRIVAL. Shot during the send-out rather than after it,
|
||||
-- because the sparkle is three quarters of a second long and the
|
||||
-- menu is well past it. Three frames close together, so one of them
|
||||
-- lands mid-burst whatever the intro's pacing does on this map.
|
||||
U.wait(70)
|
||||
if shiny then
|
||||
for k = 1, 3 do
|
||||
U.shot(game, ("%s/%d_%s_arrival_%d.png")
|
||||
:format(DIR, i, species:lower(), k))
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
|
||||
toMenu()
|
||||
U.shot(game, ("%s/%d_%s_%s.png")
|
||||
:format(DIR, i, species:lower(), variant))
|
||||
|
||||
-- and the sparkle again, armed deliberately and shot on the next
|
||||
-- frame. The arrival shots above catch it in its real moment but
|
||||
-- depend on intro timing; this one is the effect itself, on the
|
||||
-- record, at a known point in its life.
|
||||
if shiny then
|
||||
for k, v in pairs(ShinyFx.debug) do ShinyFx.debug[k] = 0 end
|
||||
ShinyFx.arm("enemy")
|
||||
U.wait(4)
|
||||
U.shot(game, ("%s/%d_%s_sparkle.png"):format(DIR, i, species:lower()))
|
||||
local d = ShinyFx.debug
|
||||
U.log((" fx: calls=%d noArena=%d noImage=%d noMesh=%d noLive=%d quads=%d")
|
||||
:format(d.calls, d.noArena, d.noImage, d.noMesh, d.noLive,
|
||||
d.quads))
|
||||
end
|
||||
|
||||
leave()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the FLAT art
|
||||
--
|
||||
-- Everything above is the STADIUM rung, which draws recoloured 3D models
|
||||
-- and never touches a pic. The tint is the other half of the feature and
|
||||
-- needs its own rung to be visible at all: 2D-3D stands the game's own
|
||||
-- battle pics up as cards, which is the path ShinyUI tints.
|
||||
OverworldBattle.setting:setValue(true, game)
|
||||
U.log("3D-BTL = " .. tostring(OverworldBattle.setting:get()) .. " (cards)")
|
||||
for _, variant in ipairs({ "normal", "shiny" }) do
|
||||
setOdds(variant == "shiny")
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(90)
|
||||
local battle = BattleState.newWild(game, "GYARADOS", 40)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.log(("cards %s: isShiny=%s"):format(
|
||||
variant, tostring(Shiny.isShiny(battle.enemy and battle.enemy.mon))))
|
||||
U.wait(70)
|
||||
toMenu()
|
||||
U.shot(game, ("%s/6_cards_gyarados_%s.png"):format(DIR, variant))
|
||||
leave()
|
||||
end
|
||||
|
||||
-- ------- the status page
|
||||
--
|
||||
-- A shiny and an ordinary mon of the SAME species, so the star is the only
|
||||
-- difference between the two images.
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
for _, variant in ipairs({ "normal", "shiny" }) do
|
||||
setOdds(variant == "shiny")
|
||||
local mon = Pokemon.new(game.data, "GYARADOS", 40)
|
||||
U.log(("summary %s: isShiny=%s flag=%s"):format(
|
||||
variant, tostring(Shiny.isShiny(mon)), tostring(mon.shiny)))
|
||||
game.save.party = { mon }
|
||||
game.stack:push(SummaryMenu.new(game, mon))
|
||||
U.wait(20)
|
||||
U.shot(game, ("%s/5_status_%s.png"):format(DIR, variant))
|
||||
leave()
|
||||
end
|
||||
|
||||
-- ------- the odds actually being odds
|
||||
--
|
||||
-- Not a screenshot, but it belongs in the same run: the rate is the thing
|
||||
-- a player experiences, and it is the one claim a picture cannot make.
|
||||
Shiny.setOdds(8192)
|
||||
local n, hits = 4000, 0
|
||||
for _ = 1, n do
|
||||
if Shiny.isShiny(Pokemon.new(game.data, "RATTATA", 5)) then
|
||||
hits = hits + 1
|
||||
end
|
||||
end
|
||||
U.log(("odds check: %d shinies in %d at 1/8192 (expect ~0-2)")
|
||||
:format(hits, n))
|
||||
|
||||
Shiny.setOdds(8192)
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,331 @@
|
||||
-- The shiny system, headless.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/shiny_test.lua [--mod=DIR]
|
||||
--
|
||||
-- Run from the PROJECT ROOT (it requires src.pokemon.Stats, the engine's own
|
||||
-- shiny predicate -- the point being that we agree with the engine rather
|
||||
-- than carry a second copy of the rule).
|
||||
--
|
||||
-- Two halves:
|
||||
--
|
||||
-- the DV model that a decided mon really does satisfy the Gen 2
|
||||
-- pattern, that a miss really does not, that the odds
|
||||
-- are the number they claim, and that the derived HP DV
|
||||
-- is kept legal.
|
||||
-- the recolour against a fixture of real (normal, shiny) colour
|
||||
-- pairs lifted from the verified texture set, so the
|
||||
-- Lua transform is checked against the Python that
|
||||
-- produced the shipped colours rather than against
|
||||
-- itself.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local args = {}
|
||||
for _, a in ipairs({ ... }) do
|
||||
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
|
||||
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
|
||||
end
|
||||
|
||||
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
|
||||
|
||||
-- ------- the mod namespace, enough of it
|
||||
|
||||
local loaded = {}
|
||||
local V = {}
|
||||
function V.require(name)
|
||||
if loaded[name] == nil then
|
||||
loaded[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(V)
|
||||
end
|
||||
return loaded[name]
|
||||
end
|
||||
function V.data(name)
|
||||
return assert(loadfile(MOD .. "/data/" .. name .. ".lua"))(V)
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
-- ------- a tiny harness
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function ok(cond, what)
|
||||
if cond then
|
||||
pass = pass + 1
|
||||
else
|
||||
fail = fail + 1
|
||||
io.write("FAIL: " .. what .. "\n")
|
||||
end
|
||||
end
|
||||
local function eq(got, want, what)
|
||||
ok(got == want, ("%s (got %s, want %s)")
|
||||
:format(what, tostring(got), tostring(want)))
|
||||
end
|
||||
|
||||
-- always-hit and always-miss stand-ins for the odds roll
|
||||
local function hit() return 1 end
|
||||
local function miss() return 2 end
|
||||
|
||||
local function mon(dvs, level, species)
|
||||
return { species = species, level = level, dvs = dvs,
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } }
|
||||
end
|
||||
|
||||
-- ------- the DV model
|
||||
|
||||
do
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
ok(Shiny.isShiny(m), "a hit produces a mon the ENGINE calls shiny")
|
||||
ok(Stats.isShiny(m.dvs), "and the engine's own predicate agrees")
|
||||
eq(m.dvs.defense, 10, "defense pinned to 10")
|
||||
eq(m.dvs.speed, 10, "speed pinned to 10")
|
||||
eq(m.dvs.special, 10, "special pinned to 10")
|
||||
eq(m.shiny, true, "the cached flag is set")
|
||||
end
|
||||
|
||||
do
|
||||
-- nearest legal Attack, so a shiny encounter is not also a stat reroll
|
||||
local cases = { [0] = 2, [4] = 3, [5] = 6, [9] = 10, [13] = 14, [15] = 15 }
|
||||
for from, want in pairs(cases) do
|
||||
local m = mon({ attack = from, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
eq(m.dvs.attack, want, ("attack %d moves to the nearest legal %d")
|
||||
:format(from, want))
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- the HP DV is derived from the low bits of the other four; a write that
|
||||
-- forgets to resync it produces a mon no real game could make
|
||||
for atk = 0, 15 do
|
||||
local m = mon({ attack = atk, defense = 3, speed = 7, special = 1, hp = 0 })
|
||||
Shiny.decide(m, hit)
|
||||
local want = (m.dvs.attack % 2) * 8 + (m.dvs.defense % 2) * 4
|
||||
+ (m.dvs.speed % 2) * 2 + (m.dvs.special % 2)
|
||||
eq(m.dvs.hp, want, "HP DV stays derived from the other four")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- a MISS must not leave an accidentally-shiny mon shiny, or the rate is
|
||||
-- the requested one and 1/8192 in parallel
|
||||
local m = mon({ attack = 2, defense = 10, speed = 10, special = 10, hp = 8 })
|
||||
ok(Stats.isShiny(m.dvs), "fixture starts out shiny by luck")
|
||||
Shiny.decide(m, miss)
|
||||
ok(not Shiny.isShiny(m), "a miss clears an accidentally-shiny mon")
|
||||
eq(m.shiny, nil, "and clears the cached flag rather than storing false")
|
||||
end
|
||||
|
||||
do
|
||||
-- the odds are the number they claim. 1/1 must be every time; a large
|
||||
-- denominator must essentially never fire on a fixed stub.
|
||||
local saved = Shiny.ODDS_DENOM
|
||||
Shiny.setOdds(1)
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 })
|
||||
Shiny.decide(m, function(_, hi) return math.random(1, hi) end)
|
||||
ok(Shiny.isShiny(m), "odds of 1 make every mon shiny")
|
||||
|
||||
Shiny.setOdds(0)
|
||||
eq(Shiny.ODDS_DENOM, 1, "a denominator below 1 is refused")
|
||||
Shiny.setOdds(saved)
|
||||
eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted")
|
||||
end
|
||||
|
||||
do
|
||||
-- ------- the row on the menu
|
||||
--
|
||||
-- The ladder's first rung is both the default and the fallback, so the
|
||||
-- canonical 1:8192 has to be it: a player who never opens the menu, and a
|
||||
-- corrupted options.lua, must both land on the games' own rate.
|
||||
local s = Shiny.setting
|
||||
eq(s.values[1], 8192, "the ladder starts at the canonical rate")
|
||||
eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows")
|
||||
eq(s.labels[#s.labels], "1:1", "the last rung is every encounter")
|
||||
eq(#s.values, #s.labels, "every rung has a label")
|
||||
for i = 2, #s.values do
|
||||
eq(s.values[i] * 2, s.values[i - 1],
|
||||
("rung %d is twice as often as the one above"):format(i))
|
||||
end
|
||||
|
||||
-- and the roll READS it. Pulled rather than pushed, so a value written by
|
||||
-- the mod manager's page -- which notifies nobody -- is seen too.
|
||||
local before = s:read()
|
||||
s:setValue(512)
|
||||
Shiny.unpinOdds()
|
||||
eq(Shiny.odds(), 512, "the roll follows the row without being told")
|
||||
eq(Shiny.ODDS_DENOM, 512, "and the live field is written through")
|
||||
s:setIndex(before)
|
||||
Shiny.setOdds(8192) -- and pinned again, for the blocks below
|
||||
end
|
||||
|
||||
do
|
||||
-- stats must follow the DVs, and a full-health mon must stay full: a wild
|
||||
-- mon that appears at less than full HP is visible in the first frame
|
||||
local Data = require("src.core.Data")
|
||||
local haveData = type(Data) == "table" and type(Data.pokemon) == "table"
|
||||
and Data.pokemon.PIKACHU ~= nil
|
||||
if haveData then
|
||||
local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 },
|
||||
10, "PIKACHU")
|
||||
m.stats = Stats.calc(Data.pokemon.PIKACHU, 10, m.dvs, m.statExp)
|
||||
m.hp = m.stats.hp
|
||||
local before = m.stats.hp
|
||||
Shiny.decide(m, hit)
|
||||
ok(m.stats.hp >= before, "max HP tracks the raised DVs")
|
||||
eq(m.hp, m.stats.hp, "a full-health mon stays full after a restat")
|
||||
else
|
||||
io.write("note: no ROM-backed data; skipped the restat check\n")
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the recolour
|
||||
|
||||
do
|
||||
eq(type(ShinyPalette.forDex(6)), "table", "colours load for Charizard")
|
||||
local ch = ShinyPalette.forDex(6)
|
||||
eq(ch.slide.h, -136, "Charizard's hue slide is the Stadium value")
|
||||
eq(ch.slide.s, -6, "and its saturation step")
|
||||
ok(ShinyPalette.forDex(130).lut ~= nil,
|
||||
"Gyarados carries an explicit table, not a slide")
|
||||
ok(ShinyPalette.forDex(6).lut == nil, "and Charizard does not")
|
||||
end
|
||||
|
||||
do
|
||||
-- alpha is never touched, and a texture with nothing to do comes back
|
||||
-- unchanged rather than rebuilt
|
||||
local fn = ShinyPalette.transform(ShinyPalette.forDex(6))
|
||||
local px = string.char(222, 131, 123, 77) .. string.char(0, 0, 0, 0)
|
||||
local out = ShinyPalette.recolorTexels(px, fn)
|
||||
eq(#out, #px, "the texel string keeps its length")
|
||||
eq(out:byte(4), 77, "alpha survives the transform")
|
||||
eq(out:byte(8), 0, "and so does a fully transparent texel's alpha")
|
||||
end
|
||||
|
||||
do
|
||||
local fixture = loadfile(MOD .. "/tests/shiny_palette_fixture.lua")
|
||||
if not fixture then
|
||||
io.write("note: no colour fixture; skipped the cross-check\n")
|
||||
else
|
||||
local rows = fixture()
|
||||
local worst, bad = 0, 0
|
||||
for _, r in ipairs(rows) do
|
||||
local fn = ShinyPalette.transform(ShinyPalette.forDex(r[1]))
|
||||
local gr, gg, gb = r[2], r[3], r[4]
|
||||
if fn then gr, gg, gb = fn(r[2], r[3], r[4]) end
|
||||
local d = math.max(math.abs(gr - r[5]), math.abs(gg - r[6]),
|
||||
math.abs(gb - r[7]))
|
||||
if d > worst then worst = d end
|
||||
if d > 2 then bad = bad + 1 end
|
||||
end
|
||||
eq(bad, 0, ("%d colour pairs reproduce the shipped textures"):format(#rows))
|
||||
-- a unit or two of drift is the two languages' float rounding; more than
|
||||
-- that means the colour model itself has diverged
|
||||
ok(worst <= 2, ("worst channel drift is %d, within rounding"):format(worst))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the read side
|
||||
--
|
||||
-- The extraction writes NNNs.dsm beside NNN.dsm; this is the other half of
|
||||
-- that contract. Driven off a directory of real packs when one is given
|
||||
-- (--packs=DIR, e.g. the extract test's --out), because the interesting
|
||||
-- cases are a shiny pack that EXISTS and one that does not, and both have
|
||||
-- to be real files for the fallback to be worth testing.
|
||||
if args.packs then
|
||||
local Vp = { mod = { log = { warn = function() end, info = function() end } } }
|
||||
local loadedP = {}
|
||||
function Vp.require(name)
|
||||
if loadedP[name] == nil then
|
||||
loadedP[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(Vp)
|
||||
end
|
||||
return loadedP[name]
|
||||
end
|
||||
-- stand in for the mod's own reader, pointed at the pack directory
|
||||
function Vp.mod:read(rel)
|
||||
local name = rel:match("([^/]+)$")
|
||||
local fp = io.open(args.packs .. "/" .. name, "rb")
|
||||
if not fp then return nil end
|
||||
local b = fp:read("*a")
|
||||
fp:close()
|
||||
return b
|
||||
end
|
||||
local StadiumPack = Vp.require("StadiumPack")
|
||||
StadiumPack.DIR = "."
|
||||
|
||||
local normal = StadiumPack.load(6, false)
|
||||
local shiny = StadiumPack.load(6, true)
|
||||
ok(normal ~= nil, "the normal Charizard pack loads")
|
||||
ok(shiny ~= nil, "and so does the shiny one")
|
||||
if normal and shiny then
|
||||
eq(shiny.shiny, true, "the shiny model is flagged as such")
|
||||
eq(normal.shiny, nil, "and the normal one is not")
|
||||
ok(normal ~= shiny, "they are SEPARATE models, not one shared table")
|
||||
eq(#normal.prims, #shiny.prims, "same geometry")
|
||||
eq(normal.texCount, shiny.texCount, "same texture count")
|
||||
-- the point of the whole exercise: different pixels
|
||||
local differs = false
|
||||
for i = 1, math.min(#normal.textures, #shiny.textures) do
|
||||
if normal.textures[i].rgba ~= shiny.textures[i].rgba then
|
||||
differs = true
|
||||
break
|
||||
end
|
||||
end
|
||||
ok(differs, "and different texels")
|
||||
end
|
||||
|
||||
-- a species with no shiny pack must fall back rather than vanish: that is
|
||||
-- what an install from before rev 3 looks like
|
||||
local missing = StadiumPack.load(999, true)
|
||||
eq(missing, nil, "an out-of-range species is still nil")
|
||||
end
|
||||
|
||||
-- ------- end to end, through the engine's own constructor
|
||||
--
|
||||
-- The wrap is the whole feature: if Pokemon.new does not carry the verdict,
|
||||
-- nothing downstream has anything to draw. Exercised against the real
|
||||
-- constructor and the fixture dataset rather than a stub, because what is
|
||||
-- being tested is precisely that we hooked the thing the game calls.
|
||||
|
||||
do
|
||||
local okKit, T = pcall(require, "tests.modkit")
|
||||
local Data = okKit and T.fixtures and T.fixtures.load()
|
||||
local okPk, Pokemon = pcall(require, "src.pokemon.Pokemon")
|
||||
local species = Data and Data.pokemon
|
||||
and (Data.pokemon.PIKACHU and "PIKACHU"
|
||||
or next(Data.pokemon))
|
||||
if not (okKit and okPk and species) then
|
||||
io.write("note: no fixture dataset; skipped the end-to-end check\n")
|
||||
else
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
ShinyBattle.install()
|
||||
ShinyBattle.install() -- twice: the wrap must not stack
|
||||
|
||||
local saved = Shiny.ODDS_DENOM
|
||||
|
||||
Shiny.setOdds(1)
|
||||
local m = Pokemon.new(Data, species, 7)
|
||||
ok(Shiny.isShiny(m), "a mon built at odds 1 comes out shiny")
|
||||
eq(m.shiny, true, "and carries the flag")
|
||||
eq(m.hp, m.stats.hp, "and is at full health despite the restat")
|
||||
ok(Stats.isShiny(m.dvs), "and the ENGINE agrees it is shiny")
|
||||
|
||||
-- and at long odds it essentially never is: 400 draws at 1/8192 would
|
||||
-- fire about 5% of the time, so a single failure here is signal
|
||||
Shiny.setOdds(8192)
|
||||
local shinies = 0
|
||||
for _ = 1, 400 do
|
||||
if Shiny.isShiny(Pokemon.new(Data, species, 7)) then
|
||||
shinies = shinies + 1
|
||||
end
|
||||
end
|
||||
ok(shinies <= 2, ("400 mons at 1/8192 produced %d shinies")
|
||||
:format(shinies))
|
||||
|
||||
Shiny.setOdds(saved)
|
||||
end
|
||||
end
|
||||
|
||||
io.write(("\n%d passed, %d failed\n"):format(pass, fail))
|
||||
os.exit(fail == 0 and 0 or 1)
|
||||
@@ -1,12 +1,17 @@
|
||||
-- The Lua ROM extractor, against the Python packer that is its oracle.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR]
|
||||
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR] [--mod=DIR]
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Defaults: the ROM under
|
||||
-- model_extract/baseroms/, the oracle in assets/stadium (whatever
|
||||
-- tools/stadium_pack.py last wrote there).
|
||||
--
|
||||
-- --mod points at the mod directory whose lib/ is under test. It exists for
|
||||
-- worktrees: the ROM and the packs are both gitignored, so a worktree has
|
||||
-- neither, and without this the test would load the SHARED checkout's
|
||||
-- modules while claiming to test the branch's.
|
||||
--
|
||||
-- ------- what this is for
|
||||
--
|
||||
-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of
|
||||
@@ -30,7 +35,7 @@ for _, a in ipairs({ ... }) do
|
||||
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
|
||||
end
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
|
||||
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
|
||||
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
|
||||
|
||||
@@ -46,6 +51,9 @@ function V.require(name)
|
||||
return loaded[name]
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
-- the mod's own directory, so a module that loads a data file finds it
|
||||
-- relative to the MOD rather than to wherever this was run from
|
||||
V.path = MOD
|
||||
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumBuild = V.require("StadiumBuild")
|
||||
@@ -79,6 +87,7 @@ if args.only then
|
||||
end
|
||||
|
||||
local checked, matched, missing, failed = 0, 0, 0, 0
|
||||
local shinyOk, shinyMissing, shinyBad = 0, 0, 0
|
||||
local firstBad = nil
|
||||
local t0 = os.clock()
|
||||
|
||||
@@ -93,6 +102,28 @@ for fileno = 0, StadiumRom.N_POKEMON - 1 do
|
||||
if args.out then
|
||||
local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb")
|
||||
if fp then fp:write(res.bytes) fp:close() end
|
||||
-- the shiny variant too, so the pair can be diffed out of process
|
||||
if res.shinyBytes then
|
||||
local sp = io.open(("%s/%03ds.dsm"):format(args.out, res.species), "wb")
|
||||
if sp then sp:write(res.shinyBytes) sp:close() end
|
||||
end
|
||||
end
|
||||
-- The shiny pack is the same DSM3 with recoloured texels, so it must be
|
||||
-- exactly as long and must actually differ. A species that produced none
|
||||
-- is counted rather than failed: it ships without a recolour and the
|
||||
-- runtime falls back to its normal model.
|
||||
if not res.shinyBytes then
|
||||
shinyMissing = shinyMissing + 1
|
||||
elseif #res.shinyBytes ~= #res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is %d bytes, normal is %d\n")
|
||||
:format(res.species, #res.shinyBytes, #res.bytes))
|
||||
elseif res.shinyBytes == res.bytes then
|
||||
shinyBad = shinyBad + 1
|
||||
io.write(("species %d: shiny pack is identical to normal\n")
|
||||
:format(res.species))
|
||||
else
|
||||
shinyOk = shinyOk + 1
|
||||
end
|
||||
local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species))
|
||||
if not want then
|
||||
@@ -119,8 +150,24 @@ io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, "
|
||||
.. "%d extractions failed (%.1fs)\n")
|
||||
:format(checked, matched, checked - matched - missing, missing,
|
||||
failed, os.clock() - t0))
|
||||
io.write(("shiny: %d recoloured, %d without a variant, %d malformed\n")
|
||||
:format(shinyOk, shinyMissing, shinyBad))
|
||||
|
||||
if matched == checked and failed == 0 and missing == 0 then
|
||||
-- NONE recoloured is a failure, not a quiet zero. It is what a missing or
|
||||
-- unfindable data/shiny_colors.lua looks like, and the first version of this
|
||||
-- test reported PASS through exactly that: 151 species built, every one of
|
||||
-- them without a shiny variant, and nothing in the output that read as
|
||||
-- wrong. A count of zero is now as loud as a malformed pack.
|
||||
if checked > 0 and shinyOk == 0 then
|
||||
io.write("NO SPECIES RECOLOURED -- data/shiny_colors.lua was not found\n")
|
||||
shinyBad = shinyBad + 1
|
||||
end
|
||||
|
||||
-- The oracle diff is the load-bearing assertion and is unchanged: the shiny
|
||||
-- pass must not have moved a single byte of the normal packs. The shiny
|
||||
-- counters are additional, and a malformed variant fails the run -- a pack
|
||||
-- of the wrong length would be read as a corrupt model at runtime.
|
||||
if matched == checked and failed == 0 and missing == 0 and shinyBad == 0 then
|
||||
io.write("PASS -- the Lua extractor reproduces the packer exactly\n")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,816 @@
|
||||
-- Driver: the tile picker -- point at a tile, get the voxelize prompt.
|
||||
--
|
||||
-- The voxelize-tiles skill is driven per TILE, and its first argument is
|
||||
-- always the same thing: which map, and which cell on it. Finding that pair
|
||||
-- by hand means walking the game to the object, counting cells off a corner
|
||||
-- and hoping the count is right. This is the same window the arena editor is
|
||||
-- -- the map drawn from its OWN art, the tiles the cartridge holds, through
|
||||
-- the game's own palette -- with a cursor on it instead of an arena, and one
|
||||
-- button: COPY PROMPT.
|
||||
--
|
||||
-- What lands on the clipboard is exactly:
|
||||
--
|
||||
-- run voxelize-tile on the tile found in PALLET_TOWN- (5,4). Return
|
||||
-- screenshots of the voxelization in .claude/voxelizations/
|
||||
--
|
||||
-- on one line, ready to paste into Claude. Every copy is also appended to
|
||||
-- SHOT_DIR/prompts.txt and printed to the console, because a session of this
|
||||
-- is a LIST of jobs -- you walk a map picking out every object worth shape
|
||||
-- and hand the list over at the end, rather than pasting one and waiting.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tile_pick.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so
|
||||
-- the console is attached and the transcript alone is the list.
|
||||
--
|
||||
-- ------- the keys
|
||||
--
|
||||
-- arrows move the cursor (hold shift for 5 cells)
|
||||
-- click put the cursor on that tile
|
||||
-- [ ] previous / next map (shift: 10 at a time)
|
||||
-- c / enter COPY PROMPT for the tile under the cursor
|
||||
-- z zoom the inset in / out (shift: out)
|
||||
-- g what the plan shows: the map's own tiles, the walk grid over
|
||||
-- them, or the walk grid alone
|
||||
-- m HIDE the plan, so the window is the game -- the panel keeps
|
||||
-- the coordinates, the inset and the button, so a tile is
|
||||
-- still copied from here while looking at it standing up in 3D
|
||||
-- t stand the player on this tile, so the 3D view behind the
|
||||
-- panel is looking at the thing you are about to voxelize
|
||||
-- y screenshot (shift: with this panel in it)
|
||||
-- l re-print every prompt copied so far
|
||||
-- F1 the key legend on screen
|
||||
-- escape quit
|
||||
--
|
||||
-- ------- environment
|
||||
--
|
||||
-- TILE_MAPS=ID,ID work this list instead of every map in the game
|
||||
-- TILE_START=ID open on this map
|
||||
-- TILE_OUT=path where prompts.txt is written
|
||||
-- TILE_RUNG=N the voxel angle rung to sit on (default 3)
|
||||
-- SHOT_DIR=path screenshots, and the default prompt-list directory
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end
|
||||
|
||||
local DIR = os.getenv("SHOT_DIR")
|
||||
if not DIR or DIR == "" then DIR = ".scratchpad/tile_pick" end
|
||||
local OUT = os.getenv("TILE_OUT")
|
||||
if not OUT or OUT == "" then OUT = DIR .. "/prompts.txt" end
|
||||
local RUNG = tonumber(os.getenv("TILE_RUNG") or "") or 3
|
||||
|
||||
-- U.shot's own mkdir is Unix-flavoured and does nothing on Windows, which
|
||||
-- is where this tool is driven from -- so the shell is asked in its own
|
||||
-- language, or the screenshots and the prompt list never reach disk.
|
||||
local WINDOWS = love.system and love.system.getOS() == "Windows"
|
||||
local function mkdirp(dir)
|
||||
if not dir or dir == "" then return end
|
||||
if WINDOWS then
|
||||
local d = dir:gsub("/", "\\")
|
||||
os.execute('if not exist "' .. d .. '" mkdir "' .. d .. '"')
|
||||
else
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
end
|
||||
end
|
||||
mkdirp(DIR)
|
||||
mkdirp(OUT:match("^(.*)[/\\][^/\\]+$"))
|
||||
|
||||
-- ------- the mod
|
||||
--
|
||||
-- Only for the camera rung: the picking itself is engine-only, but the
|
||||
-- view behind the panel is what `t` is for, and a flat 2D world behind a
|
||||
-- 3D-tile picker would be looking at the wrong thing.
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local wasRung
|
||||
if lib then
|
||||
local DayNight = lib.require("DayNight")
|
||||
DayNight.setting:setValue("day") -- one light to look at them all in
|
||||
wasRung = Pipelines.level("voxel")
|
||||
Pipelines.setLevel("voxel", RUNG)
|
||||
else
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- the picker still works, but the "
|
||||
.. "view behind it will be the flat 2D map")
|
||||
end
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- ------- which maps
|
||||
--
|
||||
-- EVERY map, unlike the arena tools: a tile worth voxelizing is as likely
|
||||
-- to be a shop counter or a bedroom desk as anything on a route, and those
|
||||
-- are exactly the maps a "where can a fight happen" filter throws away.
|
||||
local ids = {}
|
||||
local explicit = os.getenv("TILE_MAPS")
|
||||
if explicit and explicit ~= "" then
|
||||
for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
|
||||
else
|
||||
for id, def in pairs(game.data.maps) do
|
||||
if type(id) == "string" and type(def) == "table" and def.width then
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(ids)
|
||||
end
|
||||
if #ids == 0 then
|
||||
U.log("no maps to work on")
|
||||
return
|
||||
end
|
||||
|
||||
local S = {
|
||||
i = 1,
|
||||
map = nil,
|
||||
cx = 0, cy = 0, -- the cursor, in cells
|
||||
plan = nil,
|
||||
tiles = nil, -- the map baked from its own tileset
|
||||
planMode = 1,
|
||||
showPlan = true, -- is the plan drawn at all
|
||||
zoom = 5, -- how many cells across the inset shows
|
||||
msg = "",
|
||||
legend = true,
|
||||
copied = {}, -- every prompt this session, in order
|
||||
shooting = false,
|
||||
quit = false,
|
||||
planRect = nil, -- where the plan landed, for the mouse
|
||||
buttonRect = nil, -- and the button
|
||||
}
|
||||
|
||||
local queue = {}
|
||||
local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end
|
||||
local function say(fmt, ...)
|
||||
S.msg = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
local function mapId() return ids[S.i] end
|
||||
|
||||
-- ------- the prompt
|
||||
--
|
||||
-- One line, and the ONLY thing this tool exists to produce. Kept in one
|
||||
-- function so the text that reaches the clipboard, the console and the
|
||||
-- file is provably the same text.
|
||||
local function promptFor(id, cx, cy)
|
||||
return ("run voxelize-tile on the tile found in %s- (%d,%d). "
|
||||
.. "Return screenshots of the voxelization in "
|
||||
.. ".claude/voxelizations/"):format(id, cx, cy)
|
||||
end
|
||||
|
||||
local function copyPrompt()
|
||||
local text = promptFor(mapId(), S.cx, S.cy)
|
||||
local ok = false
|
||||
if love.system and love.system.setClipboardText then
|
||||
ok = pcall(love.system.setClipboardText, text)
|
||||
end
|
||||
S.copied[#S.copied + 1] = text
|
||||
-- The clipboard holds ONE of these and the session produces many, so the
|
||||
-- file is not a convenience -- it is where the list actually lives. Open
|
||||
-- per copy and closed again: a tool driving a game can be killed at any
|
||||
-- moment, and a buffered list is a lost afternoon.
|
||||
local f = io.open(OUT, "a")
|
||||
if f then
|
||||
f:write(text, "\n")
|
||||
f:close()
|
||||
end
|
||||
U.log("PROMPT " .. text)
|
||||
say("%s (%d,%d) copied%s -- %d so far", mapId(), S.cx, S.cy,
|
||||
ok and "" or " to the list only (no clipboard here)", #S.copied)
|
||||
end
|
||||
|
||||
-- ------- what the map looks like from above
|
||||
--
|
||||
-- Lifted from the arena editor, and for its reason: the plan is the
|
||||
-- surface a tile is chosen on, and a grid of coloured squares is a map of
|
||||
-- the walk grid rather than of the place. You are picking out a bookshelf.
|
||||
-- Baked once per map into a canvas, during UPDATE rather than inside a
|
||||
-- draw, because it binds a canvas of its own.
|
||||
local PLAN_MAX = 2048
|
||||
|
||||
local function paletteFor(m)
|
||||
local ow = game.overworld
|
||||
if ow and ow.paletteNameFor then
|
||||
local okName, name = pcall(ow.paletteNameFor, ow, m)
|
||||
if okName and name then
|
||||
local okPal, colours = pcall(PaletteFX.pal, game.data, name)
|
||||
if okPal and colours then return colours end
|
||||
end
|
||||
end
|
||||
local okOg, og = pcall(PaletteFX.ogBg)
|
||||
return okOg and og or nil
|
||||
end
|
||||
|
||||
local function bakeTiles()
|
||||
if S.tiles then pcall(S.tiles.release, S.tiles) end
|
||||
S.tiles = nil
|
||||
local m = S.map
|
||||
if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then
|
||||
return
|
||||
end
|
||||
local w, h = m.widthCells * 16, m.heightCells * 16
|
||||
local scale = math.min(1, PLAN_MAX / math.max(w, h))
|
||||
local okNew, canvas = pcall(love.graphics.newCanvas,
|
||||
math.max(1, math.floor(w * scale)),
|
||||
math.max(1, math.floor(h * scale)))
|
||||
if not (okNew and canvas) then return end
|
||||
-- Nearest, unlike the arena editor's plan: that one is a whole route
|
||||
-- minified to a panel, where nearest keeps a moire. This one gets
|
||||
-- magnified in the inset, where a tile has to be READABLE -- linear
|
||||
-- there is a smear of a bookshelf, and the drawing is the whole point.
|
||||
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||
|
||||
local g = love.graphics
|
||||
local prev = g.getCanvas()
|
||||
local wasTrue = m.renderer.trueColor
|
||||
m.renderer.trueColor = false
|
||||
local ok, err = pcall(function()
|
||||
g.push("all")
|
||||
g.origin()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.scale(scale, scale)
|
||||
local shader = PaletteFX.shader()
|
||||
local colours = paletteFor(m)
|
||||
if shader and colours then
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, colours)
|
||||
end
|
||||
m.renderer:drawMapOnly(0, 0, w, h)
|
||||
g.pop()
|
||||
end)
|
||||
m.renderer.trueColor = wasTrue
|
||||
if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end
|
||||
if not ok then
|
||||
pcall(canvas.release, canvas)
|
||||
U.log("could not bake the map's tiles: " .. tostring(err))
|
||||
return
|
||||
end
|
||||
S.tiles = canvas
|
||||
end
|
||||
|
||||
local PLAN_MODES = { "both", "tiles", "grid" }
|
||||
|
||||
local CELL_COLOUR = {
|
||||
open = { 0.78, 0.74, 0.63 },
|
||||
grass = { 0.33, 0.60, 0.30 },
|
||||
water = { 0.24, 0.44, 0.78 },
|
||||
warp = { 0.85, 0.68, 0.20 },
|
||||
solid = { 0.17, 0.17, 0.20 },
|
||||
}
|
||||
|
||||
local function buildPlan()
|
||||
local m = S.map
|
||||
if not m then S.plan = nil return end
|
||||
local w, h = m.widthCells, m.heightCells
|
||||
local plan = { w = w, h = h }
|
||||
for cy = 0, h - 1 do
|
||||
for cx = 0, w - 1 do
|
||||
local kind
|
||||
if not m:isWalkableCell(cx, cy) then
|
||||
kind = m:isWaterCell(cx, cy) and "water" or "solid"
|
||||
elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then
|
||||
kind = "warp"
|
||||
elseif m.isGrassCell and m:isGrassCell(cx, cy) then
|
||||
kind = "grass"
|
||||
else
|
||||
kind = "open"
|
||||
end
|
||||
plan[cy * w + cx] = kind
|
||||
end
|
||||
end
|
||||
S.plan = plan
|
||||
end
|
||||
|
||||
-- ------- moving between maps
|
||||
|
||||
local function enterMap(index)
|
||||
S.i = ((index - 1) % #ids) + 1
|
||||
local id = mapId()
|
||||
local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end)
|
||||
if not ok then
|
||||
S.map, S.plan = nil, nil
|
||||
say("could not enter %s (%s)", id, tostring(err))
|
||||
U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
|
||||
return
|
||||
end
|
||||
S.map = game.overworld.map
|
||||
buildPlan()
|
||||
bakeTiles()
|
||||
-- the middle of the map, which is where the furniture tends to be
|
||||
S.cx = math.floor(S.map.widthCells / 2)
|
||||
S.cy = math.floor(S.map.heightCells / 2)
|
||||
say("%s -- %dx%d cells", id, S.map.widthCells, S.map.heightCells)
|
||||
U.log(("MAP %s (%d/%d) %dx%d cells"):format(id, S.i, #ids,
|
||||
S.map.widthCells, S.map.heightCells))
|
||||
end
|
||||
|
||||
-- ------- the frame
|
||||
--
|
||||
-- The driver harness turns the frame cap off (a scripted run is not
|
||||
-- paced), so each step is held to a 60th of a second by hand.
|
||||
local lastT = love.timer.getTime()
|
||||
local function step(n)
|
||||
for _ = 1, (n or 1) do
|
||||
local rest = (1 / 60) - (love.timer.getTime() - lastT)
|
||||
if rest > 0 then love.timer.sleep(rest) end
|
||||
lastT = love.timer.getTime()
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
|
||||
local function shoot(withHud)
|
||||
-- the plan being away is part of what a panel shot IS, or the two write
|
||||
-- one file twice and whichever came last is the only one kept
|
||||
local tag = ""
|
||||
if withHud then tag = S.showPlan and "_panel" or "_panel_noplan" end
|
||||
local name = ("%s_x%d_y%d%s"):format(mapId():lower(), S.cx, S.cy, tag)
|
||||
local path = ("%s/%s.png"):format(DIR, name)
|
||||
S.shooting = not withHud
|
||||
game.capturePath = path
|
||||
for _ = 1, 120 do
|
||||
if not game.capturePath then break end
|
||||
step(1)
|
||||
end
|
||||
step(2)
|
||||
S.shooting = false
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
say("shot %s.png", name)
|
||||
U.log("SHOT " .. path)
|
||||
else
|
||||
say("screenshot did not reach disk: %s", path)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stand the player on the tile, so the world behind the panel is looking
|
||||
-- at the thing about to be voxelized. The tile itself is usually SOLID --
|
||||
-- a counter, a shelf, a house -- so the walk of shame outwards: the
|
||||
-- nearest walkable cell to it, searched in rings, which is where a player
|
||||
-- would have to stand to see it anyway.
|
||||
local function standHere()
|
||||
local m = S.map
|
||||
if not m then return end
|
||||
for r = 0, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local x, y = S.cx + dx, S.cy + dy
|
||||
if m:inBounds(x, y) and m:isWalkableCell(x, y) then
|
||||
game.overworld.player.cellX = x
|
||||
game.overworld.player.cellY = y
|
||||
say("standing at %d,%d", x, y)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
say("nowhere walkable within 6 cells of %d,%d", S.cx, S.cy)
|
||||
end
|
||||
|
||||
-- ------- the keys
|
||||
|
||||
local function shifted()
|
||||
return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")
|
||||
end
|
||||
|
||||
local function moveCursor(dx, dy)
|
||||
local m = S.map
|
||||
if not m then return end
|
||||
local n = shifted() and 5 or 1
|
||||
S.cx = math.max(0, math.min(m.widthCells - 1, S.cx + dx * n))
|
||||
S.cy = math.max(0, math.min(m.heightCells - 1, S.cy + dy * n))
|
||||
end
|
||||
|
||||
local function handle(key)
|
||||
if key == "left" then moveCursor(-1, 0) return true end
|
||||
if key == "right" then moveCursor(1, 0) return true end
|
||||
if key == "up" then moveCursor(0, -1) return true end
|
||||
if key == "down" then moveCursor(0, 1) return true end
|
||||
if key == "[" then post("map", shifted() and -10 or -1) return true end
|
||||
if key == "]" then post("map", shifted() and 10 or 1) return true end
|
||||
if key == "c" or key == "return" or key == "kpenter" then
|
||||
copyPrompt()
|
||||
return true
|
||||
end
|
||||
if key == "z" then
|
||||
S.zoom = math.max(3, math.min(21, S.zoom + (shifted() and 2 or -2)))
|
||||
return true
|
||||
end
|
||||
if key == "g" then
|
||||
S.planMode = (S.planMode % #PLAN_MODES) + 1
|
||||
say("plan: %s", PLAN_MODES[S.planMode])
|
||||
return true
|
||||
end
|
||||
-- The plan covers most of the window, and the thing UNDER it is the game
|
||||
-- -- the tile standing up in 3D, which is what the voxelizing is for.
|
||||
-- With the plan away, the panel still carries the coordinates, the inset
|
||||
-- and the button, so a tile can still be copied while looking at it.
|
||||
if key == "m" then
|
||||
S.showPlan = not S.showPlan
|
||||
say("plan %s", S.showPlan and "shown" or "hidden -- the game is under it")
|
||||
return true
|
||||
end
|
||||
if key == "t" then standHere() return true end
|
||||
if key == "y" then post("shot", shifted() and "hud" or nil) return true end
|
||||
if key == "l" then
|
||||
if #S.copied == 0 then
|
||||
U.log("nothing copied yet")
|
||||
else
|
||||
for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end
|
||||
end
|
||||
say("%d prompt%s printed to the console", #S.copied,
|
||||
#S.copied == 1 and "" or "s")
|
||||
return true
|
||||
end
|
||||
if key == "f1" then S.legend = not S.legend return true end
|
||||
if key == "escape" then S.quit = true return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local innerKey = love.keypressed
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
local ok, claimed = pcall(handle, key)
|
||||
if ok and claimed then return end
|
||||
if not ok then U.log("key error: " .. tostring(claimed)) end
|
||||
if innerKey then return innerKey(key, scancode, isrepeat) end
|
||||
end
|
||||
|
||||
-- The mouse is the reason this tool is faster than counting cells: click
|
||||
-- the bookshelf, read the coordinates back off the panel, copy. Both hit
|
||||
-- tests read rectangles the draw left behind, so what is clickable is
|
||||
-- exactly what was drawn -- no second copy of the layout to drift.
|
||||
local innerMouse = love.mousepressed
|
||||
function love.mousepressed(x, y, button, istouch, presses)
|
||||
if button == 1 then
|
||||
local b = S.buttonRect
|
||||
if b and x >= b[1] and y >= b[2] and x <= b[1] + b[3]
|
||||
and y <= b[2] + b[4] then
|
||||
copyPrompt()
|
||||
return
|
||||
end
|
||||
local r = S.planRect
|
||||
if r and S.map and x >= r.x and y >= r.y and x <= r.x + r.w
|
||||
and y <= r.y + r.h then
|
||||
S.cx = math.max(0, math.min(S.map.widthCells - 1,
|
||||
math.floor((x - r.x) / r.cell)))
|
||||
S.cy = math.max(0, math.min(S.map.heightCells - 1,
|
||||
math.floor((y - r.y) / r.cell)))
|
||||
return
|
||||
end
|
||||
end
|
||||
if innerMouse then
|
||||
return innerMouse(x, y, button, istouch, presses)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local font = love.graphics.newFont(13)
|
||||
local smallFont = love.graphics.newFont(11)
|
||||
local bigFont = love.graphics.newFont(16)
|
||||
|
||||
local function drawPlan(x, y, w, h)
|
||||
local plan = S.plan
|
||||
S.planRect = nil
|
||||
if not plan then return end
|
||||
local g = love.graphics
|
||||
local cell = math.min(w / plan.w, h / plan.h)
|
||||
if cell < 1 then cell = 1 end
|
||||
local pw, ph = plan.w * cell, plan.h * cell
|
||||
local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2
|
||||
S.planRect = { x = ox, y = oy, w = pw, h = ph, cell = cell }
|
||||
|
||||
g.setColor(0, 0, 0, 0.55)
|
||||
g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8)
|
||||
|
||||
local mode = PLAN_MODES[S.planMode or 1]
|
||||
if S.tiles and mode ~= "grid" then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(),
|
||||
ph / S.tiles:getHeight())
|
||||
end
|
||||
if mode ~= "tiles" then
|
||||
local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22
|
||||
for cy = 0, plan.h - 1 do
|
||||
for cx = 0, plan.w - 1 do
|
||||
local kind = plan[cy * plan.w + cx]
|
||||
local c = CELL_COLOUR[kind]
|
||||
if c and not (alpha < 1 and kind == "open") then
|
||||
g.setColor(c[1], c[2], c[3], alpha)
|
||||
g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if ow and ow.player then
|
||||
g.setColor(1, 1, 1, 0.8)
|
||||
g.circle("fill", ox + (ow.player.cellX + 0.5) * cell,
|
||||
oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4))
|
||||
end
|
||||
|
||||
-- The cursor, with crosshair arms out to the edges of the plan. On a
|
||||
-- route the cell is three pixels across and a box round it is invisible;
|
||||
-- the arms are what actually says WHICH one is selected.
|
||||
local cx, cy = ox + S.cx * cell, oy + S.cy * cell
|
||||
g.setColor(1, 0.85, 0.2, 0.35)
|
||||
g.setLineWidth(1)
|
||||
g.line(ox, cy + cell / 2, ox + pw, cy + cell / 2)
|
||||
g.line(cx + cell / 2, oy, cx + cell / 2, oy + ph)
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.setLineWidth(2)
|
||||
g.rectangle("line", cx - 1, cy - 1, cell + 2, cell + 2)
|
||||
g.setLineWidth(1)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- The tile itself, magnified. The whole judgement this tool supports is
|
||||
-- "is THAT the drawing I mean to voxelize", and at plan scale a cell is a
|
||||
-- few pixels. Drawn as a scissored blow-up of the same baked canvas rather
|
||||
-- than a second render of the map: one bake, one truth.
|
||||
local function drawInset(x, y, size)
|
||||
local g = love.graphics
|
||||
g.setColor(0, 0, 0, 0.7)
|
||||
g.rectangle("fill", x - 3, y - 3, size + 6, size + 6)
|
||||
if not (S.tiles and S.map) then return end
|
||||
local span = S.zoom -- cells across the inset
|
||||
local pxPerCell = S.tiles:getWidth() / S.map.widthCells
|
||||
local mag = size / (span * pxPerCell)
|
||||
-- the top-left canvas pixel the inset starts at, so the cursor's cell
|
||||
-- sits in the middle of it
|
||||
local sx = (S.cx + 0.5 - span / 2) * pxPerCell
|
||||
local sy = (S.cy + 0.5 - span / 2) * pxPerCell
|
||||
g.setScissor(x, y, size, size)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(S.tiles, x - sx * mag, y - sy * mag, 0, mag, mag)
|
||||
g.setScissor()
|
||||
local cell = size / span
|
||||
local mx = x + size / 2 - cell / 2
|
||||
local my = y + size / 2 - cell / 2
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.setLineWidth(2)
|
||||
g.rectangle("line", mx, my, cell, cell)
|
||||
g.setLineWidth(1)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local LEGEND = {
|
||||
"arrows move the cursor (shift x5)",
|
||||
"click put the cursor on that tile",
|
||||
"[ ] previous / next map (shift x10)",
|
||||
"c COPY PROMPT (also enter, also the button)",
|
||||
"z zoom the inset in (shift: out)",
|
||||
"g plan: tiles / walk grid / both",
|
||||
"m hide the plan (the game is under it)",
|
||||
"t stand the player on this tile",
|
||||
"y screenshot (shift: with this panel)",
|
||||
"l list every prompt copied so far",
|
||||
"F1 this list escape quit",
|
||||
}
|
||||
|
||||
local function drawOverlay()
|
||||
local g = love.graphics
|
||||
local W, H = g.getDimensions()
|
||||
local panel = math.min(320, math.floor(W * 0.34))
|
||||
|
||||
g.setColor(0, 0, 0, 0.72)
|
||||
g.rectangle("fill", 0, 0, panel, H)
|
||||
|
||||
local y = 10
|
||||
g.setFont(bigFont)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.print(mapId(), 10, y)
|
||||
y = y + 22
|
||||
g.setFont(font)
|
||||
g.setColor(0.72, 0.78, 0.9, 1)
|
||||
g.print(("map %d / %d"):format(S.i, #ids), 10, y)
|
||||
y = y + 20
|
||||
|
||||
-- the coordinates, big, because they are what the prompt is made of and
|
||||
-- what gets read back against the game
|
||||
g.setFont(bigFont)
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.print(("tile (%d,%d)"):format(S.cx, S.cy), 10, y)
|
||||
y = y + 24
|
||||
g.setFont(smallFont)
|
||||
g.setColor(0.8, 0.8, 0.85, 1)
|
||||
local m = S.map
|
||||
if m then
|
||||
local okTile, tile = pcall(m.cellTile, m, S.cx, S.cy)
|
||||
local kind = S.plan and S.plan[S.cy * S.plan.w + S.cx] or "?"
|
||||
-- tileset and tile id are not in the prompt, and are here anyway: they
|
||||
-- are what the skill's data files are keyed by, so this is the line
|
||||
-- that tells you two different-looking cells are the same drawing
|
||||
g.print(("tileset %s tile %s %s")
|
||||
:format(tostring(m.def and m.def.tileset),
|
||||
okTile and tostring(tile) or "?", kind), 10, y)
|
||||
y = y + 16
|
||||
g.print(("block (%d,%d)"):format(math.floor(S.cx / 2),
|
||||
math.floor(S.cy / 2)), 10, y)
|
||||
y = y + 18
|
||||
end
|
||||
|
||||
-- the inset
|
||||
local inset = math.min(panel - 20, 180)
|
||||
drawInset(10, y, inset)
|
||||
y = y + inset + 14
|
||||
|
||||
-- THE BUTTON. A real rectangle with a real hit test, not a hint that a
|
||||
-- key exists: the tool is used with one hand on the mouse walking a map,
|
||||
-- and reaching for the keyboard per tile is the whole friction.
|
||||
local bw, bh = panel - 20, 34
|
||||
S.buttonRect = { 10, y, bw, bh }
|
||||
local mx, my = love.mouse.getPosition()
|
||||
local hot = mx >= 10 and my >= y and mx <= 10 + bw and my <= y + bh
|
||||
g.setColor(hot and 0.30 or 0.18, hot and 0.62 or 0.42, hot and 0.34 or 0.22,
|
||||
1)
|
||||
g.rectangle("fill", 10, y, bw, bh, 5, 5)
|
||||
g.setColor(0.45, 0.9, 0.55, 1)
|
||||
g.rectangle("line", 10, y, bw, bh, 5, 5)
|
||||
g.setFont(font)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
local label = "COPY PROMPT"
|
||||
g.print(label, 10 + (bw - font:getWidth(label)) / 2, y + 9)
|
||||
y = y + bh + 12
|
||||
|
||||
-- what it will copy, shown in full and wrapped: the prompt is the
|
||||
-- product, so it is never hidden behind the button that makes it
|
||||
g.setFont(smallFont)
|
||||
g.setColor(0.65, 0.75, 0.85, 1)
|
||||
local preview = promptFor(mapId(), S.cx, S.cy)
|
||||
local _, lines = smallFont:getWrap(preview, panel - 20)
|
||||
for _, l in ipairs(lines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 8
|
||||
|
||||
g.setColor(0.55, 0.9, 0.6, 1)
|
||||
local _, outLines = smallFont:getWrap(
|
||||
("%d copied -> %s"):format(#S.copied, OUT), panel - 20)
|
||||
for _, l in ipairs(outLines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 5
|
||||
|
||||
if S.msg ~= "" then
|
||||
g.setColor(1, 0.9, 0.6, 1)
|
||||
local _, msgLines = smallFont:getWrap(S.msg, panel - 20)
|
||||
for _, l in ipairs(msgLines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 6
|
||||
end
|
||||
|
||||
if S.legend then
|
||||
y = y + 6
|
||||
g.setColor(0.75, 0.8, 0.9, 1)
|
||||
for _, l in ipairs(LEGEND) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 14
|
||||
end
|
||||
end
|
||||
|
||||
if S.showPlan then
|
||||
local px = panel + 12
|
||||
drawPlan(px, 12, W - px - 12, H - 24)
|
||||
else
|
||||
-- and with nothing drawn there, nothing there is clickable: the mouse
|
||||
-- reads the rectangle the draw leaves behind, so clearing it is what
|
||||
-- stops a click on the game moving the cursor
|
||||
S.planRect = nil
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local innerDraw = love.draw
|
||||
function love.draw()
|
||||
if innerDraw then innerDraw() end
|
||||
if S.shooting then return end
|
||||
local g = love.graphics
|
||||
g.push("all")
|
||||
g.origin()
|
||||
local ok, err = pcall(drawOverlay)
|
||||
g.pop()
|
||||
if not ok and not S.drawWarned then
|
||||
S.drawWarned = true
|
||||
U.log("overlay draw failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the session
|
||||
|
||||
U.log("tile picker -- " .. #ids .. " maps")
|
||||
for _, l in ipairs(LEGEND) do U.log(" " .. l) end
|
||||
U.log("prompts append to " .. OUT)
|
||||
|
||||
local start = os.getenv("TILE_START")
|
||||
local at = 1
|
||||
if start and start ~= "" then
|
||||
for i, id in ipairs(ids) do
|
||||
if id == start then at = i break end
|
||||
end
|
||||
end
|
||||
enterMap(at)
|
||||
|
||||
local function dispatch(job)
|
||||
if job.cmd == "map" then
|
||||
enterMap(S.i + job.arg)
|
||||
elseif job.cmd == "shot" then
|
||||
shoot(job.arg == "hud")
|
||||
end
|
||||
end
|
||||
|
||||
-- One frame of the session: the next thing a key asked for, or a frame of
|
||||
-- standing still while the game runs underneath. Map changes and captures
|
||||
-- go through the queue rather than happening inside the key handler --
|
||||
-- both of them bind canvases, and doing that from inside love.keypressed
|
||||
-- is doing it inside the engine's own frame.
|
||||
local function pump(frames)
|
||||
for _ = 1, (frames or 1) do
|
||||
local job = table.remove(queue, 1)
|
||||
if job then dispatch(job) else step(1) end
|
||||
if S.quit then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- TILE_SELFTEST=1: the same session, with the keys pressed for you
|
||||
--
|
||||
-- Not a substitute for sitting in front of it -- the thing this tool is
|
||||
-- judged on is whether a bookshelf is recognisable in the inset -- but it
|
||||
-- proves the session starts, both wraps hold, every key path runs, a map
|
||||
-- change re-bakes, and a prompt reaches the clipboard and the file. Which
|
||||
-- is the whole of what can fail without a person noticing.
|
||||
if truthy(os.getenv("TILE_SELFTEST")) then
|
||||
OUT = DIR .. "/selftest_prompts.txt"
|
||||
local function press(key, frames)
|
||||
local ok, err = pcall(handle, key)
|
||||
if not ok then U.log("SELFTEST key " .. key .. " failed: " .. tostring(err)) end
|
||||
pump(frames or 20)
|
||||
end
|
||||
press("right")
|
||||
press("down")
|
||||
press("left")
|
||||
press("up")
|
||||
press("c") -- a prompt, copied
|
||||
press("z") -- the inset both ways
|
||||
press("z")
|
||||
press("g") -- each way of drawing the plan
|
||||
press("g")
|
||||
press("g")
|
||||
press("t") -- stand on it
|
||||
press("m") -- the plan away, and back -- with a shot of
|
||||
post("shot", "hud") -- the window without it, which is the only
|
||||
pump(240) -- thing that proves the panel stands alone
|
||||
press("m")
|
||||
press("]", 90) -- the next map, which re-bakes
|
||||
press("c") -- and a prompt from there
|
||||
press("[", 90) -- back
|
||||
press("y", 240) -- a screenshot of the world
|
||||
post("shot", "hud") -- and one WITH the panel, which is the only
|
||||
pump(240) -- thing that proves the overlay draws
|
||||
press("l") -- the list
|
||||
press("f1")
|
||||
-- the click paths, which the key presses above never touch
|
||||
local b = S.buttonRect
|
||||
if b then
|
||||
love.mousepressed(b[1] + 4, b[2] + 4, 1)
|
||||
pump(20)
|
||||
end
|
||||
local r = S.planRect
|
||||
if r then
|
||||
love.mousepressed(r.x + r.w * 0.5, r.y + r.h * 0.5, 1)
|
||||
pump(20)
|
||||
end
|
||||
U.log(("SELFTEST %d prompts, cursor %d,%d on %s -- %s")
|
||||
:format(#S.copied, S.cx, S.cy, mapId(), tostring(S.msg)))
|
||||
press("escape", 5)
|
||||
S.quit = true
|
||||
end
|
||||
|
||||
while not S.quit do pump(1) end
|
||||
|
||||
-- Hand the install back the way it was found.
|
||||
if S.tiles then pcall(S.tiles.release, S.tiles) end
|
||||
love.draw = innerDraw
|
||||
love.keypressed = innerKey
|
||||
love.mousepressed = innerMouse
|
||||
if wasRung then pcall(Pipelines.setLevel, "voxel", wasRung) end
|
||||
|
||||
if #S.copied > 0 then
|
||||
U.log(("-- %d prompt%s this session"):format(#S.copied,
|
||||
#S.copied == 1 and "" or "s"))
|
||||
for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end
|
||||
end
|
||||
U.log("done -- " .. OUT)
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Scratch driver: Celadon Gym's little tree ($40/$41 over $50/$51,
|
||||
-- one cell) at CELADON_GYM (5,7), flanked by the hedge cylinders.
|
||||
-- Front and back, voxel rung (5) and flat rung (3). Same spots BEFORE
|
||||
-- and AFTER the pin change.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tree_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/celtree 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/celtree")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[celtree] 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 = "GYM",
|
||||
imageWidth = 128,
|
||||
imageHeight = 48 } })
|
||||
for _, t in ipairs({ 64, 80 }) do
|
||||
local s = shapes[t]
|
||||
print("[celtree] running-mod GYM 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 = {
|
||||
-- the tree at (5,7) seen from the south = its front
|
||||
{ map = "CELADON_GYM", x = 5, y = 8, face = "up", label = "tree_front" },
|
||||
-- the same tree from the north = its back
|
||||
{ map = "CELADON_GYM", x = 5, y = 6, face = "down", label = "tree_back" },
|
||||
-- a step further back so the whole tree fits over the hedges
|
||||
{ map = "CELADON_GYM", x = 5, y = 9, face = "up", label = "tree_far" },
|
||||
-- from the open floor west of it, edge-on
|
||||
{ map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "tree_west" },
|
||||
-- the second placement at (7,5), unobstructed in the east garden
|
||||
{ map = "CELADON_GYM", x = 7, y = 7, face = "up", label = "tree2_front" },
|
||||
{ map = "CELADON_GYM", x = 8, y = 5, face = "left", label = "tree2_east" },
|
||||
{ map = "CELADON_GYM", x = 7, y = 3, face = "down", label = "tree2_back" },
|
||||
}
|
||||
|
||||
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("[celtree] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[celtree] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[celtree] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Scratch driver: one shot of each fixture the wall-top / statue pass
|
||||
-- touched, so the change can be looked at rather than reasoned about.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_fix_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxel_fix AB_TAG=. lovec.exe .
|
||||
--
|
||||
-- SHOT_DIR is relative to the PROJECT ROOT, which is where lovec runs from --
|
||||
-- the mod's own .claude/ is where these belong, so it has to be spelt out.
|
||||
--
|
||||
-- Every scene stands the player on the nearest WALKABLE cell to the vantage
|
||||
-- named below (teleporting into a wall puts the close cameras inside the
|
||||
-- geometry), faces the fixture, and shoots at voxel rung 3 -- the higher
|
||||
-- top-down camera, which is the one that shows what a wall wears on TOP.
|
||||
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/voxel_fix")
|
||||
.. "/" .. (os.getenv("AB_TAG") or ".")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[voxfix] 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")
|
||||
local TileShape = V.require("TileShape")
|
||||
|
||||
-- prove the game is reading THIS tree, not a stale installed copy
|
||||
local ht = TileShape.wallTop and TileShape.wallTop("HOUSE")
|
||||
print(("[voxfix] wallTop present=%s HOUSE(45)=%s")
|
||||
:format(tostring(TileShape.wallTop ~= nil),
|
||||
tostring(ht and ht(45))))
|
||||
|
||||
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
|
||||
|
||||
-- the nearest walkable cell to the vantage, searched in rings -- a
|
||||
-- vantage picked off a map dump is often the fixture itself
|
||||
local function place(x, y, face)
|
||||
local m = game.overworld.map
|
||||
for r = 0, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local px, py = x + dx, y + dy
|
||||
if m:inBounds(px, py) and m:isWalkableCell(px, py) then
|
||||
game.overworld.player.cellX = px
|
||||
game.overworld.player.cellY = py
|
||||
game.overworld.player.facing = face
|
||||
return px, py
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "BLUES_HOUSE", x = 4, y = 3, face = "up",
|
||||
label = "house_wall_top" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 4, y = 3, face = "up",
|
||||
label = "pokecenter_wall_top" },
|
||||
{ map = "ROCKET_HIDEOUT_ELEVATOR", x = 2, y = 3, face = "up",
|
||||
label = "rocket_lift_wall_top" },
|
||||
{ map = "REDS_HOUSE_2F", x = 6, y = 3, face = "up",
|
||||
label = "reds_2f_wall_top" },
|
||||
{ map = "REDS_HOUSE_1F", x = 5, y = 3, face = "up",
|
||||
label = "reds_1f_wall_top" },
|
||||
{ map = "LANCES_ROOM", x = 6, y = 15, face = "up",
|
||||
label = "lances_room_statues" },
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up",
|
||||
label = "indigo_plateau_statue" },
|
||||
-- the west-edge statue itself, close enough to count the birds on it
|
||||
{ map = "INDIGO_PLATEAU", x = 1, y = 4, face = "up",
|
||||
label = "indigo_plateau_statue_close", rung = 5 },
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 4, face = "up",
|
||||
label = "indigo_plateau_statue_near" },
|
||||
}
|
||||
|
||||
local only = os.getenv("VF_ONLY")
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
if not (only and only ~= "" and not only:find(s.label, 1, true)) then
|
||||
local ok, err = pcall(function()
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
local px, py = place(s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", s.rung or 3)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
print(("[voxfix] %s -> %s (stood %d,%d)"):format(s.map, path, px, py))
|
||||
else
|
||||
print("[voxfix] capture missed: " .. path)
|
||||
end
|
||||
end)
|
||||
if not ok then print("[voxfix] " .. s.map .. " failed: " .. tostring(err)) end
|
||||
end
|
||||
end
|
||||
print(("[voxfix] %d/%d shots into %s"):format(shots, #SCENES, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
+265
-2
@@ -680,6 +680,80 @@ TEMPLATES = {
|
||||
roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
|
||||
slab=3, front_eave=0, ledge=None, tileset="mansion",
|
||||
),
|
||||
# F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7)
|
||||
# (1 placement) -- 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.
|
||||
"mansion_square_table": dict(
|
||||
tiles=[
|
||||
[38, 39, 39, 41],
|
||||
[54, 55, 55, 57],
|
||||
[54, 55, 55, 57],
|
||||
[60, 58, 58, 59],
|
||||
],
|
||||
roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
|
||||
slab=3, front_eave=0, ledge=None, tileset="mansion",
|
||||
),
|
||||
# F07c: the Game Freak office's computer desk -- CELADON_MANSION_2F
|
||||
# cell (0,5) and CELADON_MANSION_3F cells (0,3), (3,3) and (0,6)
|
||||
# (4 placements, scan.lua, and the four ids of its apron and chair
|
||||
# row occur nowhere else on this atlas).
|
||||
#
|
||||
# Rows 0-15 are BILL'S DESK, byte for byte: the same tabletop seen
|
||||
# from above with the same terminal, cord and isometric computer
|
||||
# drawn into it (a whole-crop diff against the interior atlas's
|
||||
# 11/12/13/14 + 27/28/29/30 comes back empty for every one of the
|
||||
# 512 pixels). One drawing = one model, so those four parts are
|
||||
# bills_desk's verbatim -- see that entry for the readings.
|
||||
#
|
||||
# Only the FRONT differs, and it is drawn 6 rows where Bill's is 7:
|
||||
# 15 the top's own black front edge -- the row the lid
|
||||
# replaces, so it opens the fascia the way Bill's row 16
|
||||
# does and the desk stands 7 voxels rather than 8
|
||||
# 16-17 the #555 edge lip and the black seam under it: the
|
||||
# desktop's own rim, so `fascia` (it wraps every side)
|
||||
# 18-21 the base -- the left leg (the @#@ at x0-x2), the open
|
||||
# apron between, and the DRAWER PEDESTAL at x20-x31: two
|
||||
# #555 drawer fronts (rows 16-17 and 19-20) in a black
|
||||
# frame, which the measured recess pass sinks a voxel
|
||||
# each because they are non-black regions sealed behind
|
||||
# their own black outline. Row 21 is the ground line, and
|
||||
# the measured 22 is where the drawing puts the cast
|
||||
# shadow on the floor -- 22-23 are that shadow, dithered
|
||||
# floor in the walkable cell, and the model builds none
|
||||
# of it.
|
||||
# The chair is Bill's chair REDRAWN, not the same pixels (a 2px
|
||||
# white margin round the backrest panel where Bill's has 1px, and
|
||||
# it sits at x4-x15 rather than x2-x13), but the same object band
|
||||
# for band, so it takes that part table with x and rise adjusted:
|
||||
# rows 20-21 the backrest top seen from above, 22-31 its elevation,
|
||||
# rise = -7 putting it back on the floor, z 22 depth 10.
|
||||
"mansion_computer_desk": dict(
|
||||
tiles=[
|
||||
[36, 37, 52, 53],
|
||||
[64, 65, 66, 67],
|
||||
[2, 3, 85, 86],
|
||||
[18, 19, 17, 17],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="mansion", 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=dict(fascia=(15, 17), base=(18, 21), depth=2),
|
||||
parts=[
|
||||
dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes
|
||||
dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13),
|
||||
z=8, depth=6), # the keyboard
|
||||
dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9),
|
||||
z=8, depth=2), # the cord
|
||||
dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9),
|
||||
dict(kind="upright", x=(4, 15), top=(20, 21), facade=(22, 31),
|
||||
rise=-7, z=22, depth=10), # the chair
|
||||
],
|
||||
),
|
||||
# F08: the dining table of the generic town house -- 18 placements,
|
||||
# every home's cells (3,3):(4,4) -- the chief's long table (F07) at
|
||||
# two cells wide. The same read to the row: 0-23 the rounded
|
||||
@@ -853,6 +927,79 @@ TEMPLATES = {
|
||||
z=3, depth=11, stretch=True), # the stool
|
||||
],
|
||||
),
|
||||
# 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). A DIFFERENT drawing from
|
||||
# the house stool -- it sits one row HIGHER in the tile (seat top
|
||||
# rows 4-9 over front edge 10 and legs 11-14, with a clear floor
|
||||
# row below) and its leg detail differs -- but the same object band
|
||||
# for band, so it takes the same 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.
|
||||
"diner_stool": dict(
|
||||
tiles=[
|
||||
[7, 8],
|
||||
[23, 24],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="lobby",
|
||||
panes=False,
|
||||
parts=[
|
||||
dict(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 Celadon diner and the roof terrace --
|
||||
# 4 placements, all on the lobby 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", no matches on
|
||||
# any other atlas). The drawing packs three facings no band split
|
||||
# can reach: rows 0-23 are the OCTAGONAL top seen from above (24
|
||||
# top-view rows = 24 depth rows, the same 1:1 every tabletop is
|
||||
# drawn with -- so the plan is the silhouette itself, 32 wide by 24
|
||||
# deep), rows 24-25 the slab's own fascia (#555 over black, folded
|
||||
# down the rim under the band's drawn outline -> slab 3), and rows
|
||||
# 26-31 the PEDESTAL seen under the front edge: the base's top
|
||||
# surface with the dark column rising from its middle (26-27), its
|
||||
# lit south half (28-29), and its front arc curving to the floor
|
||||
# (30-31). The flattened arcs are horizontal CIRCLES seen from
|
||||
# above -- depth, not narrowing -- so the pedestal is two discs:
|
||||
# base diameter 16 (drawn cols 8-23), column diameter 6 (the dark
|
||||
# blob's cols 13-18), both centred on the drawn centre x=16, plan
|
||||
# centre z=12. MEASURED: plan, diameters, centre, 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 (the
|
||||
# projection cannot state either; the drawn base arc suggests a
|
||||
# low disc). depth 3 keeps the plot to the drawn plan's 24 rows;
|
||||
# the grid's 4th tile row is the pedestal's own drawing and the
|
||||
# floor tile at its southeast corner, which the claim paints as
|
||||
# ground. `scrub` repoints the top's interior field -- the four
|
||||
# $37 tiles, which are ALSO the checkerboard floor's light half
|
||||
# and 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).
|
||||
"diner_round_table": dict(
|
||||
tiles=[
|
||||
[9, 39, 39, 25],
|
||||
[54, 55, 55, 57],
|
||||
[70, 55, 55, 71],
|
||||
[85, 86, 87, 55],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="lobby", depth=3,
|
||||
panes=False, scrub=[(8, 8, 23, 23)],
|
||||
parts=[
|
||||
dict(kind="disc", cx2=32, cz2=24, r=8, rise=0, h=2,
|
||||
side=dict(rows=(30, 31), x=(13, 18)),
|
||||
cap=dict(rows=(26, 29), x=(9, 22))), # the base
|
||||
dict(kind="disc", cx2=32, cz2=24, r=3, rise=2, h=3,
|
||||
side=dict(rows=(26, 27), x=(14, 17))), # the column
|
||||
dict(kind="plan", x=(0, 31), rows=(0, 23),
|
||||
fascia=(24, 25), fascia_x=(8, 23), rise=5), # the top
|
||||
],
|
||||
),
|
||||
# F04: the Pokemon Center's PC -- every Center's northeast corner
|
||||
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
|
||||
# shares this atlas. The lab desk-set read again: a
|
||||
@@ -1350,7 +1497,7 @@ def build_desk_set(sp, pr, t):
|
||||
|
||||
def build_parts(plane):
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
x0, x1 = p.get("x", (0, W - 1))
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
# `at` names the sheet's own height when it does not lie
|
||||
@@ -1462,6 +1609,91 @@ def build_desk_set(sp, pr, t):
|
||||
if pr0 <= sy <= pr1 and inside(sx, sy):
|
||||
put(sx, plane + y, z, sx, sy)
|
||||
continue
|
||||
if p["kind"] == "plan":
|
||||
# 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.
|
||||
r0, r1 = p["rows"]
|
||||
f0, f1 = p["fascia"]
|
||||
fx0, fx1 = p["fascia_x"]
|
||||
rise = p.get("rise", 0)
|
||||
h = (f1 - f0 + 1) + 1
|
||||
dark = shade_px.get(DARK) or shade_px[BLACK]
|
||||
|
||||
def drawn(sx, z):
|
||||
return (x0 <= sx <= x1 and 0 <= z <= r1 - r0
|
||||
and inside(sx, r0 + z))
|
||||
|
||||
for z in range(r1 - r0 + 1):
|
||||
if not 0 <= z < D:
|
||||
continue
|
||||
sy = r0 + z
|
||||
for sx in range(x0, x1 + 1):
|
||||
if not inside(sx, sy):
|
||||
continue
|
||||
put(sx, rise + h - 1, z, sx, sy)
|
||||
edge = not (drawn(sx - 1, z) and drawn(sx + 1, z)
|
||||
and drawn(sx, z - 1) and drawn(sx, z + 1))
|
||||
for y in range(rise, rise + h - 1):
|
||||
if edge:
|
||||
put(sx, y, z, max(fx0, min(fx1, sx)),
|
||||
f0 + (rise + h - 2 - y))
|
||||
else:
|
||||
put(sx, y, z, dark[0], dark[1])
|
||||
continue
|
||||
if p["kind"] == "disc":
|
||||
# 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.
|
||||
r, rise, h = p["r"], p.get("rise", 0), p["h"]
|
||||
s0, s1 = p["side"]["rows"]
|
||||
sa0, sa1 = p["side"]["x"]
|
||||
sn = s1 - s0 + 1
|
||||
cap = p.get("cap")
|
||||
|
||||
def in_disc(x, z):
|
||||
dx = 2 * x + 1 - p["cx2"]
|
||||
dz = 2 * z + 1 - p["cz2"]
|
||||
return dx * dx + dz * dz <= 4 * r * r
|
||||
|
||||
zlo = (p["cz2"] - 2 * r) // 2
|
||||
for x in range((p["cx2"] - 2 * r) // 2,
|
||||
(p["cx2"] + 2 * r) // 2 + 1):
|
||||
for z in range(max(0, zlo),
|
||||
min(D - 1, (p["cz2"] + 2 * r) // 2) + 1):
|
||||
if not in_disc(x, z):
|
||||
continue
|
||||
edge = not (in_disc(x - 1, z) and in_disc(x + 1, z)
|
||||
and in_disc(x, z - 1)
|
||||
and in_disc(x, z + 1))
|
||||
for y in range(rise, rise + h):
|
||||
if cap and y == rise + h - 1 and not edge:
|
||||
c0, c1 = cap["rows"]
|
||||
sy = min(c1, c0 + ((z - zlo)
|
||||
* (c1 - c0 + 1)) // (2 * r))
|
||||
sx = max(cap["x"][0], min(cap["x"][1], x))
|
||||
else:
|
||||
sy = s0 + (rise + h - 1 - y) % sn
|
||||
sx = max(sa0, min(sa1, x))
|
||||
put(x, y, z, sx, sy)
|
||||
continue
|
||||
tr0, tr1 = p["top"]
|
||||
fr0, fr1 = p["facade"]
|
||||
pd = p["depth"]
|
||||
@@ -1960,7 +2192,7 @@ def verify_desk_set(vox, pr, t):
|
||||
# sink) -- and nothing stands anywhere else
|
||||
tops = {}
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
x0, x1 = p.get("x", (0, W - 1))
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
z0 = p.get("z", r0)
|
||||
@@ -2006,6 +2238,37 @@ def verify_desk_set(vox, pr, t):
|
||||
for y in range(plane, plane + h + 1):
|
||||
assert (x, y, z) in vox, \
|
||||
f"iso box hole at {x},{y},{z}"
|
||||
elif p["kind"] == "plan":
|
||||
# its one geometric intent: the plan IS the drawn band's
|
||||
# silhouette -- a solid slab column wherever the band draws
|
||||
r0, r1 = p["rows"]
|
||||
h = (p["fascia"][1] - p["fascia"][0] + 1) + 1
|
||||
rise = p.get("rise", 0)
|
||||
for x in range(x0, x1 + 1):
|
||||
for z in range(min(r1 - r0 + 1, D)):
|
||||
if not pr["inside"](x, r0 + z):
|
||||
continue
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1)
|
||||
for y in range(rise, rise + h):
|
||||
assert (x, y, z) in vox, \
|
||||
f"plan slab hole at {x},{y},{z}"
|
||||
elif p["kind"] == "disc":
|
||||
# its one geometric intent: a solid circle in plan at every
|
||||
# layer, symmetric about the authored centre
|
||||
r, rise, h = p["r"], p.get("rise", 0), p["h"]
|
||||
in_disc = lambda x, z: ((2 * x + 1 - p["cx2"]) ** 2
|
||||
+ (2 * z + 1 - p["cz2"]) ** 2
|
||||
<= 4 * r * r)
|
||||
for x in range((p["cx2"] - 2 * r) // 2,
|
||||
(p["cx2"] + 2 * r) // 2 + 1):
|
||||
for z in range(D):
|
||||
if not in_disc(x, z):
|
||||
continue
|
||||
assert in_disc(p["cx2"] - 1 - x, z), \
|
||||
f"disc asymmetric at {x},{z}"
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1)
|
||||
for y in range(rise, rise + h):
|
||||
assert (x, y, z) in vox, f"disc hole at {x},{y},{z}"
|
||||
else:
|
||||
fr0, fr1 = p["facade"]
|
||||
ytp = plane + p.get("rise", 0) + (fr1 - fr0)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Dump every species' ADVANCED palette and its shiny-shifted twin, as JSON.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json
|
||||
--
|
||||
-- Run from the PROJECT ROOT.
|
||||
--
|
||||
-- The game's battle pics are four-shade DMG grey (0/85/170/255) -- there is
|
||||
-- no colour in the art at all. Under ADVANCED (`redpp`) the colour comes
|
||||
-- entirely from a per-species palette applied over the top, which is why a
|
||||
-- shiny SPRITE is a shifted palette rather than repainted art. This dumps
|
||||
-- both halves so a comparison sheet can be built from them.
|
||||
--
|
||||
-- Reads the real generated data directly rather than going through
|
||||
-- PaletteFX: the headless fixture dataset carries FIXMON placeholders, not
|
||||
-- the 151, so a dump driven through it is of nothing.
|
||||
|
||||
local POK = dofile("data/generated/pokemon.lua")
|
||||
local PACK = dofile("data/palettes_gbc.lua")
|
||||
|
||||
local V = { path = "mods/DramaticShapeVoxelMod" }
|
||||
local loaded = {}
|
||||
function V.require(name)
|
||||
if loaded[name] == nil then
|
||||
loaded[name] = assert(loadfile(V.path .. "/lib/" .. name .. ".lua"))(V)
|
||||
end
|
||||
return loaded[name]
|
||||
end
|
||||
V.mod = { log = { warn = function() end, info = function() end } }
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local mons = POK.pokemon or POK
|
||||
local rows = {}
|
||||
for species, def in pairs(mons) do
|
||||
local dex = type(def) == "table" and def.dex
|
||||
if type(species) == "string" and dex and dex >= 1 and dex <= 151 then
|
||||
rows[#rows + 1] = { species = species, dex = dex,
|
||||
name = def.name or species }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function esc(s) return (tostring(s):gsub('"', '\\"')) end
|
||||
|
||||
io.write("[\n")
|
||||
for i, r in ipairs(rows) do
|
||||
local palName = PACK.pokemon[r.species]
|
||||
local pal = palName and PACK.palettes[palName]
|
||||
if pal then
|
||||
local fn = ShinyPalette.paletteTransform(r.dex)
|
||||
local n, s = {}, {}
|
||||
for k = 1, 4 do
|
||||
local c = pal[k] or pal[#pal]
|
||||
local cr, cg, cb = c[1], c[2], c[3]
|
||||
n[k] = ("[%d,%d,%d]"):format(cr, cg, cb)
|
||||
if fn then
|
||||
local sr, sg, sb = fn(cr, cg, cb)
|
||||
s[k] = ("[%d,%d,%d]"):format(sr, sg, sb)
|
||||
else
|
||||
s[k] = n[k]
|
||||
end
|
||||
end
|
||||
io.write(('%s{"dex":%d,"species":"%s","name":"%s","pal":"%s",'
|
||||
.. '"normal":[%s],"shiny":[%s],"shifted":%s}')
|
||||
:format(i > 1 and ",\n" or "", r.dex, esc(r.species), esc(r.name),
|
||||
esc(palName), table.concat(n, ","), table.concat(s, ","),
|
||||
fn and "true" or "false"))
|
||||
end
|
||||
end
|
||||
io.write("\n]\n")
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Emit the mod's data/shiny_colors.lua from the validated Stadium tables.
|
||||
|
||||
PROVENANCE. data/shiny_colors.lua is 62KB of generated data and this is
|
||||
where it came from, so it is checked in even though its INPUTS are not:
|
||||
they live under .claude/shiny_stadium/ (the research tree: the transcribed
|
||||
Stadium colour table, the extracted texture pairs, the inventory that marks
|
||||
which textures are effects), and that path is gitignored along with
|
||||
everything else derived from the ROM.
|
||||
|
||||
So this will not run from a fresh clone, and it is not meant to -- the Lua
|
||||
it writes is the shipped artefact. It is here to record HOW the numbers were
|
||||
arrived at, and to be re-runnable by anyone who still has the research tree.
|
||||
|
||||
The values themselves are the Stadium games' own: a hue rotation in degrees
|
||||
plus saturation and lightness on a quantized -8..+8 scale at 12.5% a step.
|
||||
The column order in the source table is H, L, S -- hue, LIGHTNESS,
|
||||
saturation -- which was checked against the canonical pokeemerald palettes
|
||||
across all 146 sliding species before being trusted.
|
||||
|
||||
|
||||
Two kinds of entry, because Stadium itself has two kinds of shiny:
|
||||
|
||||
slide {h, l, s} the 146 species Stadium recolours by sliding the whole
|
||||
model in HSL. h is degrees; l and s are Stadium's
|
||||
-8..+8 steps at 12.5% each. Three numbers reproduce the
|
||||
whole model, so this is data-cheap and exact.
|
||||
|
||||
lut {from = to} the 5 species Stadium ships a genuine alternate texture
|
||||
for (Clefairy, Clefable, Jigglypuff, Wigglytuff,
|
||||
Gyarados). No slide can express those -- Jigglypuff's
|
||||
body must stay pink while its irises rotate to green --
|
||||
so they carry an explicit colour mapping, sampled from
|
||||
the verified texture pairs. Exact by construction, and
|
||||
only the colours that actually change are listed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MOD = os.path.dirname(HERE)
|
||||
# the research tree (gitignored -- see the provenance note above)
|
||||
SS = os.path.join(MOD, ".claude", "shiny_stadium")
|
||||
OUT = os.path.join(MOD, "data", "shiny_colors.lua")
|
||||
|
||||
vals = json.load(open(os.path.join(SS, "stadium_shiny_values.json"), encoding="utf-8"))
|
||||
V = vals["species"]
|
||||
|
||||
inv = json.load(open(os.path.join(SS, "texture_inventory.json"), encoding="utf-8"))
|
||||
models = inv["models"] if isinstance(inv, dict) and "models" in inv else inv
|
||||
if isinstance(models, dict):
|
||||
models = list(models.values())
|
||||
kind = {}
|
||||
for m in models:
|
||||
dex = str(m.get("dex") or m.get("species") or "").zfill(3)
|
||||
for t in m.get("textures", []):
|
||||
kind[(dex, int(t.get("index", -1)))] = t.get("kind", "body")
|
||||
|
||||
TEX = os.path.join(SS, "textures")
|
||||
slugs = {d.split("_")[0]: d for d in os.listdir(os.path.join(TEX, "normal"))}
|
||||
|
||||
|
||||
def lut_for(dex):
|
||||
"""Distinct colour mapping over this species' BODY textures only.
|
||||
|
||||
fx textures are excluded exactly as the build excluded them: a shiny
|
||||
Gyarados has shiny scales and an ordinary Hyper Beam.
|
||||
"""
|
||||
d = slugs[dex]
|
||||
pairs = {}
|
||||
for fn in sorted(os.listdir(os.path.join(TEX, "normal", d))):
|
||||
idx = int(fn.replace("tex_", "").replace(".png", ""))
|
||||
if kind.get((dex, idx), "body") != "body":
|
||||
continue
|
||||
a = Image.open(os.path.join(TEX, "normal", d, fn)).convert("RGBA")
|
||||
b = Image.open(os.path.join(TEX, "shiny", d, fn)).convert("RGBA")
|
||||
if a.size != b.size:
|
||||
continue
|
||||
pa, pb = a.load(), b.load()
|
||||
for y in range(a.size[1]):
|
||||
for x in range(a.size[0]):
|
||||
ca, cb = pa[x, y], pb[x, y]
|
||||
if ca[3] < 8:
|
||||
continue
|
||||
pairs.setdefault(ca[:3], cb[:3])
|
||||
return {k: v for k, v in pairs.items() if k != v}
|
||||
|
||||
|
||||
def dominant(dex):
|
||||
"""The most-covering opaque colour across this species' BODY textures.
|
||||
|
||||
Deliberately not the mean: a mean over a Pokemon with a light belly and a
|
||||
dark back lands on a mid-grey that belongs to neither, and the tint drawn
|
||||
from it would be no tint. The modal colour is a real colour off the model.
|
||||
"""
|
||||
d = slugs[dex]
|
||||
counts = {}
|
||||
for fn in sorted(os.listdir(os.path.join(TEX, "normal", d))):
|
||||
idx = int(fn.replace("tex_", "").replace(".png", ""))
|
||||
if kind.get((dex, idx), "body") != "body":
|
||||
continue
|
||||
im = Image.open(os.path.join(TEX, "normal", d, fn)).convert("RGBA")
|
||||
px = im.load()
|
||||
for y in range(im.size[1]):
|
||||
for x in range(im.size[0]):
|
||||
c = px[x, y]
|
||||
if c[3] < 8:
|
||||
continue
|
||||
# skip the near-black and near-white structural colours:
|
||||
# outlines and eye whites are on every model and say nothing
|
||||
# about which Pokemon this is
|
||||
if max(c[:3]) < 30 or min(c[:3]) > 225:
|
||||
continue
|
||||
counts[c[:3]] = counts.get(c[:3], 0) + 1
|
||||
if not counts:
|
||||
return None
|
||||
return max(counts.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
|
||||
lines = []
|
||||
w = lines.append
|
||||
|
||||
w("-- Shiny colours for the 151 Stadium models, as the Stadium games define")
|
||||
w("-- them. GENERATED -- do not hand-edit. The colour model is documented in")
|
||||
w("-- the header of lib/ShinyPalette.lua.")
|
||||
w("--")
|
||||
w("-- Stadium does not ship a second set of textures for a shiny Pokemon. It")
|
||||
w("-- slides the colours it already has in HSL: a hue rotation in degrees,")
|
||||
w("-- plus saturation and lightness on a quantized -8..+8 scale where one")
|
||||
w("-- step is 12.5% (so +-8 is +-100%, exactly GIMP's Hue-Saturation range --")
|
||||
w("-- s = -8 is full greyscale, l = +8 is white).")
|
||||
w("--")
|
||||
w("-- FIVE SPECIES ARE DIFFERENT. Clefairy, Clefable, Jigglypuff, Wigglytuff")
|
||||
w("-- and Gyarados get a genuine alternate texture in Stadium, because no")
|
||||
w("-- single slide can produce their shiny: Jigglypuff's body must stay pink")
|
||||
w("-- while its irises rotate to green, and one rotation moves both or")
|
||||
w("-- neither. Those five carry `lut` -- an explicit before/after colour")
|
||||
w("-- mapping sampled from the verified texture pairs, listing only the")
|
||||
w("-- colours that actually change. A colour absent from the table is left")
|
||||
w("-- exactly as it was.")
|
||||
w("--")
|
||||
w("-- hueRange is the min/max hue a NON-shiny nicknamed mon can slide to in")
|
||||
w("-- Stadium (derived from the trainer ID and the nickname). Carried for")
|
||||
w("-- reference; nothing reads it today.")
|
||||
w("")
|
||||
w("return {")
|
||||
|
||||
n_slide = n_lut = n_entries = 0
|
||||
for dex in sorted(V):
|
||||
e = V[dex]
|
||||
hr = e["hue_range"]
|
||||
w(" [%d] = {" % int(dex))
|
||||
w(' name = "%s",' % e["name"])
|
||||
w(" hueRange = { min = %d, max = %d }," % (hr["min"], hr["max"]))
|
||||
dom = dominant(dex)
|
||||
if dom:
|
||||
w(" -- the colour this species is mostly MADE of, and what its"
|
||||
" shiny")
|
||||
w(" -- shift does to it. A flat sprite cannot be recoloured, only")
|
||||
w(" -- multiplied, and the multiply has to be measured against the")
|
||||
w(" -- body colour: averaged over a balanced set of references a")
|
||||
w(" -- hue rotation cancels itself out to no tint at all.")
|
||||
w(" dom = 0x%02X%02X%02X," % dom)
|
||||
if e["special_texture"]:
|
||||
n_lut += 1
|
||||
lut = lut_for(dex)
|
||||
n_entries += len(lut)
|
||||
w(" -- Stadium ships a real alternate texture for this one; %d"
|
||||
% len(lut))
|
||||
w(" -- colours move, every other colour stays exactly as it was.")
|
||||
w(" lut = {")
|
||||
row = []
|
||||
for src in sorted(lut):
|
||||
dst = lut[src]
|
||||
row.append("[0x%02X%02X%02X]=0x%02X%02X%02X,"
|
||||
% (src[0], src[1], src[2], dst[0], dst[1], dst[2]))
|
||||
if len(row) == 4:
|
||||
w(" " + " ".join(row))
|
||||
row = []
|
||||
if row:
|
||||
w(" " + " ".join(row))
|
||||
w(" },")
|
||||
else:
|
||||
n_slide += 1
|
||||
h = e["hsl"]
|
||||
w(" slide = { h = %d, l = %d, s = %d }," % (h["h"], h["l"], h["s"]))
|
||||
w(" },")
|
||||
|
||||
w("}")
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
open(OUT, "w", encoding="utf-8").write("\n".join(lines) + "\n")
|
||||
print("wrote", OUT)
|
||||
print("slide species:", n_slide, " lut species:", n_lut,
|
||||
" lut entries:", n_entries)
|
||||
print("bytes:", os.path.getsize(OUT))
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Every species' battle palette, normal beside shiny, as one HTML page.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Writes
|
||||
-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html
|
||||
--
|
||||
-- ------- what it is actually showing
|
||||
--
|
||||
-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's
|
||||
-- values for a model's texels, and it is already checked against the Python
|
||||
-- that produced it. This is the other end: what those values become after
|
||||
-- ShinyPics puts them through the engine's four-shade battle palette, which
|
||||
-- is where the flat art gets its colour and the only place a mistake there
|
||||
-- shows up.
|
||||
--
|
||||
-- Two rules are visible in the output and both were bugs first:
|
||||
--
|
||||
-- * shade 1 and shade 4 never move. They are the shared paper (255,239,255)
|
||||
-- and the shared ink (25,16,16), not colours anybody chose for this
|
||||
-- animal, and sliding them turned shiny Golbat's white navy.
|
||||
-- * the five TABLE species rotate hue like everyone else, because their
|
||||
-- slide is measured back out of their lookup table rather than falling
|
||||
-- back to a multiply that can only darken.
|
||||
--
|
||||
-- The COLORS pack is whatever PaletteFX defaults to in a headless process
|
||||
-- (the GBC pack). The RED++ pack is a different set of four colours per
|
||||
-- species and would want its own sheet.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local OUT = MOD .. "/.claude/shiny_update/palettes.html"
|
||||
|
||||
-- ------- the mod namespace, enough of it (see tests/shiny_test.lua)
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua
|
||||
-- reads; any mon carrying it is shiny as far as the engine is concerned.
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
-- ------- collect, in dex order
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function hex(c)
|
||||
return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0)
|
||||
end
|
||||
|
||||
local moved, still, missing = 0, 0, 0
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
row.normal = PaletteFX.monPal(Data, row.name)
|
||||
row.palName = PaletteFX.monPalName(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
row.shiny = PaletteFX.monPal(Data, row.name)
|
||||
row.shinyName = PaletteFX.monPalName(Data, row.name)
|
||||
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
row.kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex)
|
||||
or spec.slide)
|
||||
row.slide = slide
|
||||
|
||||
if not (row.normal and row.shiny) then
|
||||
missing = missing + 1
|
||||
else
|
||||
-- how far the two middle shades actually travelled, as the largest
|
||||
-- per-channel step: a row that reads 0 is a shiny nobody can see
|
||||
local d = 0
|
||||
for i = 2, #row.normal - 1 do
|
||||
local a, b = row.normal[i], row.shiny[i]
|
||||
if type(a) == "table" and type(b) == "table" then
|
||||
for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end
|
||||
end
|
||||
end
|
||||
row.delta = d
|
||||
if d >= 8 then moved = moved + 1 else still = still + 1 end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the page
|
||||
local out = {}
|
||||
local function w(s) out[#out + 1] = s end
|
||||
|
||||
w([[<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Shiny battle palettes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 62em; margin: 0 0 20px; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { padding: 3px 10px 3px 0; text-align: left; vertical-align: middle;
|
||||
white-space: nowrap; }
|
||||
th { color: #9aa0aa; font-weight: 400; border-bottom: 1px solid #2a2c34; }
|
||||
tr:hover { background: #1c1e26; }
|
||||
.sw { display: inline-block; width: 26px; height: 20px;
|
||||
border: 1px solid #000; vertical-align: middle; }
|
||||
.fixed { opacity: .45; }
|
||||
.kind-table { color: #ffc46b; }
|
||||
.kind-slide { color: #7fb2ff; }
|
||||
.flat { color: #ff8a8a; }
|
||||
td.n { text-align: right; color: #9aa0aa; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle palettes — normal beside shiny</h1>
|
||||
<p class="lede">What <code>ShinyPics</code> hands the battle pic cache, per
|
||||
species. The first and last shades are the shared paper and ink and are held
|
||||
still on purpose (shown faded); only the two middle shades are the Pokemon.
|
||||
<span class="kind-slide">slide</span> species use Stadium's declared values;
|
||||
the five <span class="kind-table">table</span> species use a slide measured
|
||||
back out of their own lookup table, which is what lets Gyarados reach red.
|
||||
Δ is the largest per-channel step across the two middle shades —
|
||||
a row in <span class="flat">red</span> barely moved.</p>
|
||||
]])
|
||||
|
||||
w(("<p class=\"lede\">%d species · %d visibly recoloured · "
|
||||
.. "%d barely moved · %d with no palette</p>\n")
|
||||
:format(#rows, moved, still, missing))
|
||||
|
||||
w("<table><tr><th>#</th><th>species</th><th>pal</th><th>kind</th>"
|
||||
.. "<th>slide h / s / l</th><th>normal</th><th>shiny</th><th>Δ</th>"
|
||||
.. "</tr>\n")
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local function swatches(cols)
|
||||
if not cols then return "—" end
|
||||
local o = {}
|
||||
for i, c in ipairs(cols) do
|
||||
local fixed = (i == 1 or i == #cols) and " fixed" or ""
|
||||
if type(c) == "table" and c[1] then
|
||||
o[#o + 1] = ("<span class=\"sw%s\" style=\"background:%s\" "
|
||||
.. "title=\"%d,%d,%d\"></span>")
|
||||
:format(fixed, hex(c), c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(o)
|
||||
end
|
||||
local s = row.slide
|
||||
w(("<tr><td class=\"n\">%03d</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"kind-%s\">%s</td><td>%s</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"n%s\">%s</td></tr>\n")
|
||||
:format(row.dex, row.name, tostring(row.palName), row.kind, row.kind,
|
||||
s and ("%.0f° / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
|
||||
s.l or 0) or "—",
|
||||
swatches(row.normal), swatches(row.shiny),
|
||||
(row.delta and row.delta < 8) and " flat" or "",
|
||||
row.delta and tostring(row.delta) or "—"))
|
||||
end
|
||||
|
||||
w("</table></body></html>\n")
|
||||
|
||||
local f = assert(io.open(OUT, "wb"))
|
||||
f:write(table.concat(out))
|
||||
f:close()
|
||||
|
||||
print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette")
|
||||
:format(OUT, #rows, moved, still, missing))
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv
|
||||
--
|
||||
-- Run from the PROJECT ROOT. One species per line, tab separated:
|
||||
--
|
||||
-- dex name spriteFront kind n1 n2 n3 n4 s1 s2 s3 s4
|
||||
--
|
||||
-- where each colour is r,g,b. TSV rather than JSON because there is no JSON
|
||||
-- encoder in this tree and the payload is eight colours and a path.
|
||||
--
|
||||
-- The COLOURS are the point: they come from the real wrap (ShinyPics over
|
||||
-- PaletteFX.monPal), not from a second implementation of it, so what the
|
||||
-- sheet shows is what the game bakes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151
|
||||
and def.spriteFront then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function cols(t)
|
||||
local o = {}
|
||||
for i = 1, 4 do
|
||||
local c = t and t[i]
|
||||
o[i] = (type(c) == "table" and c[1])
|
||||
and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0"
|
||||
end
|
||||
return table.concat(o, "\t")
|
||||
end
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local normal = PaletteFX.monPal(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
local shiny = PaletteFX.monPal(Data, row.name)
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
local kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
io.write(("%d\t%s\t%s\t%s\t%s\t%s\n")
|
||||
:format(row.dex, row.name, row.path, kind, cols(normal),
|
||||
cols(shiny)))
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every battle pic, baked normal beside baked shiny, as one HTML page.
|
||||
|
||||
luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv
|
||||
python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv
|
||||
|
||||
Run from the PROJECT ROOT. Writes
|
||||
mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained
|
||||
(every pic is a data: URI), so it can be opened or sent on its own.
|
||||
|
||||
------- the bake is the engine's, exactly
|
||||
|
||||
src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets
|
||||
its colour, and it does it ONCE at load with mapPixel:
|
||||
|
||||
col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4]
|
||||
|
||||
Four-shade DMG art, keyed on the RED channel alone, snapped to the species
|
||||
palette. That line is reproduced below rather than approximated, because the
|
||||
whole question this sheet answers is what the player will actually see -- an
|
||||
approximation of the bake would be answering a different one.
|
||||
|
||||
The colours come from tools/shiny_pic_dump.lua, which runs the real
|
||||
ShinyPics wrap over the real PaletteFX, so nothing here re-derives them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.getcwd()
|
||||
MOD = "mods/DramaticShapeVoxelMod"
|
||||
OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html")
|
||||
SCALE = 3 # nearest-neighbour, so the pixels stay pixels
|
||||
|
||||
|
||||
def parse_color(s):
|
||||
r, g, b = (int(v) for v in s.split(","))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def bake(img, pal):
|
||||
"""getImage's mapPixel: red channel picks the shade, alpha is kept."""
|
||||
img = img.convert("RGBA")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
f = r / 255.0
|
||||
if f > 0.83:
|
||||
c = pal[0]
|
||||
elif f > 0.5:
|
||||
c = pal[1]
|
||||
elif f > 0.17:
|
||||
c = pal[2]
|
||||
else:
|
||||
c = pal[3]
|
||||
px[x, y] = (c[0], c[1], c[2], a)
|
||||
return img
|
||||
|
||||
|
||||
def data_uri(img):
|
||||
img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
||||
stream = sys.stdin if src == "-" else open(src, encoding="utf-8")
|
||||
rows = []
|
||||
with stream:
|
||||
for line in stream:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
f = line.split("\t")
|
||||
rows.append({
|
||||
"dex": int(f[0]),
|
||||
"name": f[1],
|
||||
"path": f[2],
|
||||
"kind": f[3],
|
||||
"normal": [parse_color(c) for c in f[4:8]],
|
||||
"shiny": [parse_color(c) for c in f[8:12]],
|
||||
})
|
||||
|
||||
cards, missing = [], 0
|
||||
for row in rows:
|
||||
path = os.path.join(ROOT, row["path"])
|
||||
if not os.path.exists(path):
|
||||
missing += 1
|
||||
continue
|
||||
art = Image.open(path)
|
||||
n = data_uri(bake(art.copy(), row["normal"]))
|
||||
s = data_uri(bake(art.copy(), row["shiny"]))
|
||||
cards.append(
|
||||
'<figure class="k-{kind}">'
|
||||
'<div class="pair"><img src="{n}" alt="{name} normal">'
|
||||
'<img src="{s}" alt="{name} shiny"></div>'
|
||||
'<figcaption>{dex:03d} {name}<span>{kind}</span></figcaption>'
|
||||
"</figure>".format(kind=row["kind"], n=n, s=s,
|
||||
name=row["name"], dex=row["dex"])
|
||||
)
|
||||
|
||||
html = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Shiny battle pics</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 64em; margin: 0 0 20px; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
figure { margin: 0; background: #1c1e26; border: 1px solid #2a2c34;
|
||||
border-radius: 6px; padding: 8px; }
|
||||
.k-table { border-color: #7a5a1e; }
|
||||
.pair { display: flex; gap: 6px; background: #fff; border-radius: 3px;
|
||||
padding: 2px; }
|
||||
img { display: block; image-rendering: pixelated; }
|
||||
figcaption { margin-top: 6px; color: #9aa0aa; display: flex;
|
||||
justify-content: space-between; gap: 10px; }
|
||||
figcaption span { color: #6f757f; }
|
||||
.k-table figcaption span { color: #ffc46b; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle pics — normal on the left, shiny on the right</h1>
|
||||
<p class="lede">Each pair is the same four-shade art baked twice, through the
|
||||
palette the game itself would use: <code>getImage</code> keys on the red
|
||||
channel alone and snaps to the species palette, once, at load. The colours
|
||||
come from the live <code>ShinyPics</code> wrap, so this is what the flat
|
||||
battle screen draws — not a preview of it. Bordered cards are the five
|
||||
species Stadium gives a real alternate texture, whose slide is measured back
|
||||
out of that texture rather than declared.</p>
|
||||
<p class="lede">%d species%s</p>
|
||||
<div class="grid">%s</div>
|
||||
</body></html>
|
||||
""" % (len(cards),
|
||||
"" if not missing else " · %d with no art on disk" % missing,
|
||||
"\n".join(cards))
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
size = os.path.getsize(OUT) / 1024.0
|
||||
print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing,
|
||||
size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Build the normal-vs-shiny sprite sheet for all 151.
|
||||
|
||||
luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json
|
||||
python mods/DramaticShapeVoxelMod/tools/shiny_sprite_sheet.py pals.json OUT.png
|
||||
|
||||
Run from the PROJECT ROOT.
|
||||
|
||||
WHY THIS IS A PALETTE JOB. The game's battle pics carry no colour: they are
|
||||
four-shade DMG grey (255/170/85/0 plus transparency). Under ADVANCED the
|
||||
colour comes entirely from a per-species palette laid over that art. So a
|
||||
"shiny sprite" is the same pixels under a shifted palette -- which is what
|
||||
this composites, using the real palettes out of data/palettes_gbc.lua and the
|
||||
real shift out of the mod's own colour tables.
|
||||
|
||||
Each Pokemon appears as a PAIR, normal beside shiny, because a lone shiny
|
||||
sprite says nothing about what changed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = os.getcwd()
|
||||
FRONT = os.path.join(ROOT, "assets", "generated", "battle", "front")
|
||||
|
||||
pals = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "shiny_sprites.png"
|
||||
|
||||
# the four DMG shades the art is drawn in, lightest first, matching the
|
||||
# palette's own colour order
|
||||
SHADES = [255, 170, 85, 0]
|
||||
|
||||
SCALE = 2
|
||||
SW = 40 * SCALE # sprite box
|
||||
PAD = 3
|
||||
LABEL = 11
|
||||
CELL_W = SW * 2 + PAD # a normal/shiny pair
|
||||
CELL_H = SW + LABEL
|
||||
COLS = 8 # pairs per row
|
||||
MARGIN = 10
|
||||
GAP_X, GAP_Y = 14, 8
|
||||
|
||||
|
||||
def slug_for(species):
|
||||
"""assets/generated/battle/front filenames, which are not the species key."""
|
||||
s = species.lower()
|
||||
cands = [s, s.replace("_", ""), s.replace("_", "."),
|
||||
s.replace("_f", "f").replace("_m", "m")]
|
||||
for c in cands:
|
||||
p = os.path.join(FRONT, c + ".png")
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def colorize(img, pal):
|
||||
"""Map the four DMG shades onto a palette. Alpha is carried through."""
|
||||
src = img.convert("RGBA")
|
||||
out = Image.new("RGBA", src.size, (0, 0, 0, 0))
|
||||
sp, op = src.load(), out.load()
|
||||
for y in range(src.size[1]):
|
||||
for x in range(src.size[0]):
|
||||
r, g, b, a = sp[x, y]
|
||||
if a < 8:
|
||||
continue
|
||||
# the art is grey, so any channel identifies the shade; nearest
|
||||
# rather than exact, because a scaled or re-encoded asset can be
|
||||
# a unit off
|
||||
best, bi = None, 0
|
||||
for i, sh in enumerate(SHADES):
|
||||
d = abs(r - sh)
|
||||
if best is None or d < best:
|
||||
best, bi = d, i
|
||||
c = pal[bi]
|
||||
op[x, y] = (c[0], c[1], c[2], a)
|
||||
return out
|
||||
|
||||
|
||||
rows = (len(pals) + COLS - 1) // COLS
|
||||
W = MARGIN * 2 + COLS * CELL_W + (COLS - 1) * GAP_X
|
||||
H = MARGIN * 2 + 34 + rows * (CELL_H + GAP_Y)
|
||||
|
||||
sheet = Image.new("RGB", (W, H), (24, 24, 28))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
d.text((MARGIN, 8),
|
||||
"Gen 1 battle sprites -- NORMAL (left) vs SHINY (right) of each pair."
|
||||
" ADVANCED palettes, shifted by the Stadium shiny values.",
|
||||
fill=(235, 235, 235))
|
||||
d.text((MARGIN, 21),
|
||||
"The art is 4-shade DMG grey; all colour is the palette, so a shiny"
|
||||
" sprite is the same pixels under a shifted palette.",
|
||||
fill=(150, 150, 158))
|
||||
|
||||
missing = []
|
||||
for i, e in enumerate(pals):
|
||||
path = slug_for(e["species"])
|
||||
cx = MARGIN + (i % COLS) * (CELL_W + GAP_X)
|
||||
cy = MARGIN + 34 + (i // COLS) * (CELL_H + GAP_Y)
|
||||
if not path:
|
||||
missing.append(e["species"])
|
||||
continue
|
||||
src = Image.open(path)
|
||||
n = colorize(src, e["normal"]).resize((SW, SW), Image.NEAREST)
|
||||
s = colorize(src, e["shiny"]).resize((SW, SW), Image.NEAREST)
|
||||
# a faint plate behind each half so a dark shiny is not lost on the
|
||||
# background -- the same plate under both, so it cannot flatter one
|
||||
d.rectangle([cx, cy, cx + SW - 1, cy + SW - 1], fill=(44, 44, 50))
|
||||
d.rectangle([cx + SW + PAD, cy, cx + SW * 2 + PAD - 1, cy + SW - 1],
|
||||
fill=(44, 44, 50))
|
||||
sheet.paste(n, (cx, cy), n)
|
||||
sheet.paste(s, (cx + SW + PAD, cy), s)
|
||||
tag = "%03d %s" % (e["dex"], e["name"][:11])
|
||||
same = e["normal"] == e["shiny"]
|
||||
d.text((cx + 1, cy + SW + 1), tag,
|
||||
fill=(120, 120, 128) if same else (225, 225, 232))
|
||||
|
||||
d.text((MARGIN, H - 12),
|
||||
"grey label = palette identical between the two (that species' shiny"
|
||||
" does not move this palette)",
|
||||
fill=(120, 120, 128))
|
||||
|
||||
sheet.save(OUT)
|
||||
print("wrote", OUT, sheet.size)
|
||||
if missing:
|
||||
print("no sprite for:", ", ".join(missing))
|
||||
same_n = sum(1 for e in pals if e["normal"] == e["shiny"])
|
||||
print("pairs: %d, palettes that shift: %d, identical: %d"
|
||||
% (len(pals), len(pals) - same_n, same_n))
|
||||
Reference in New Issue
Block a user