Compare commits

...

41 Commits

Author SHA1 Message Date
DramaticShape 731ecd9677 Merge pull request #34 from DramaticShape/mobile-dpi-fix
Mobile dpi fix
2026-07-31 14:10:24 -04:00
DramaticShape 851f36d46f Merge pull request #33 from DramaticShape/master
rebase
2026-07-31 14:04:17 -04:00
DramaticShape 775757b2d6 account for dpi issues on updated 3d battles 2026-07-31 13:51:54 -04:00
DramaticShape 20f1807edd Merge pull request #25 from DramaticShape/back-sprite-battles
add day/night filter to 2d
2026-07-30 22:37:52 -04:00
DramaticShape 4da8e5dc3e add day/night filter to 2d 2026-07-30 22:36:59 -04:00
DramaticShape 3eb62a5e00 Merge pull request #24 from DramaticShape/back-sprite-battles
Back sprite battles
2026-07-30 22:05:14 -04:00
DramaticShape 26d1d96d52 hide tilt/gbcfx 2026-07-30 21:57:29 -04:00
DramaticShape d9a000d7ad add back sprite option for 3d battles 2026-07-30 21:52:54 -04:00
DramaticShape f12b564dcd moved route 1 battle location again, added transparency to bottom battle menu 2026-07-30 21:34:25 -04:00
DramaticShape d1a1c69c7d Merge pull request #16 from DramaticShape/android-night-cycle-fix
iterate version to 1.2.1
2026-07-30 00:11:29 -04:00
DramaticShape 500556c3fc iterate version to 1.2.1 2026-07-30 00:11:02 -04:00
DramaticShape e621c28a74 Merge pull request #15 from DramaticShape/android-night-cycle-fix
android fix for day night cycle issue
2026-07-30 00:09:22 -04:00
DramaticShape eabc8af716 android fix for day night cycle issue 2026-07-30 00:04:52 -04:00
DramaticShape 785838e6cd Merge pull request #14 from DramaticShape/3d-battle-mod
fix issue with fight transition in oak's lab
2026-07-29 23:14:58 -04:00
DramaticShape 14f844ad8e fix issue with fight transition in oak's lab 2026-07-29 23:13:36 -04:00
DramaticShape cb325af6cf Merge pull request #13 from DramaticShape/3d-battle-mod
3d battle mod
2026-07-29 22:55:21 -04:00
DramaticShape 50f8f6bfe3 Merge branch '3d-battle-mod' of https://github.com/DramaticShape/DramaticShapeVoxelMod into 3d-battle-mod
# Conflicts:
#	README.md
2026-07-29 22:54:22 -04:00
DramaticShape 284172db7b add clock sync option 2026-07-29 22:50:54 -04:00
DramaticShape 6d27d91411 fix glinting on windows and striping 2026-07-29 22:43:20 -04:00
DramaticShape e898cedad6 Merge pull request #12 from DramaticShape/3d-battle-mod
1.2.0 updates
2026-07-29 22:31:17 -04:00
DramaticShape f2a18b62b2 Merge branch 'master' into 3d-battle-mod 2026-07-29 22:30:51 -04:00
DramaticShape 5ff56eab46 bump version 2026-07-29 22:29:23 -04:00
DramaticShape 99e18f7b5b set viridian to night time lighting 2026-07-29 22:27:25 -04:00
DramaticShape 9c263f0937 glass masking 2026-07-29 22:23:21 -04:00
DramaticShape 7e3245a9ef more shadow fixes, fix tree borders 2026-07-29 22:23:08 -04:00
DramaticShape 18857e039a fixed disconnected shadows 2026-07-29 21:51:42 -04:00
DramaticShape f841378330 add day night cycle 2026-07-29 21:42:34 -04:00
DramaticShape 6d7a93dbad remove voxel grid from overworld sprites 2026-07-29 20:09:18 -04:00
DramaticShape e14953c78a mitigate diagonal shadow artifacts 2026-07-29 20:06:18 -04:00
DramaticShape 8dc046f72e add skybox 2026-07-29 19:53:12 -04:00
DramaticShape c3ba10e17a added battle transition into overworld 2026-07-29 19:09:48 -04:00
DramaticShape 41a86b5fb2 second pass at indigo plateau 2026-07-29 19:03:11 -04:00
DramaticShape 9a34ba9c77 force 3d battle ui, initial victory road updates 2026-07-29 18:45:00 -04:00
DramaticShape 097bdcd14b fix poke mart back wall 2026-07-29 18:16:29 -04:00
DramaticShape e838ff4558 made cash register a sprite 2026-07-29 18:01:47 -04:00
DramaticShape 8e7a80055d fix couch man 2026-07-29 17:36:03 -04:00
DramaticShape 5ea70a278f update route one battle location 2026-07-29 16:22:53 -04:00
DramaticShape 60d5362c89 fix couch man 2026-07-29 15:44:52 -04:00
DramaticShape 3c1781f6b1 fix vertical line in battle sprites 2026-07-29 15:35:28 -04:00
DramaticShape b21fd46ea7 Remove battle staging details from README
Removed section about battle staging and conditions.
2026-07-28 18:49:07 -04:00
DramaticShape 0f303e7975 bump required version to 0.1.32 2026-07-28 16:14:43 -04:00
35 changed files with 7644 additions and 400 deletions
+474
View File
@@ -1,5 +1,250 @@
# Changelog
## 1.3.0
### Added
- **BACK SPRITES, a new row under 3D-BTL: your own Pokémon stays on the battle menu.**
The staged shot stands both mons on the map, which is the mode's whole claim
-- and it costs the framing Gen 1 is most recognisable by: your own Pokémon,
seen from behind, sitting on top of the battle menu with its feet on the box.
With BACK SPRITES on the foe is still geometry standing on its own tile at the far
end of the arena, and the player's side goes back to being the GB's own flat
back pic in the GB's own slot: same art, same 2x, same feet on row 96. It is
the engine's own pics layer that draws it, through the `onlySide` argument
that layer already takes, so every pic effect -- the grow-out-of-the-ball,
the faint slide, the damage blink, the send-out trainer pic -- comes along
unchanged and none of it is reimplemented.
Nothing else about the shot moves. The arena, the camera and the drift are
solved exactly as they were, so the foe stands where it always stood and the
player's cell is simply empty ground in the foreground. Two things follow the
setting: the `pokemon.sprite` hook stops asking for the front pic on the
player's side (it is a back view again, and the front art would be that mon
turned round to face the player it belongs to), and the move-animation offset
drops that side's contribution, because a pic that has not moved cannot have
moved the pair's centre.
OFF by default -- what the mode advertises is the two of them out there --
and only on the OPTIONS menu while 3D-BTL is on, since with staged battles
off the engine already draws exactly this.
### Fixed
- **A staged battle on a phone stood some Pokémon three times the size of the
square they were on.** A Pidgey towered over the arena while the mon beside
it was the right size, which reads as a bug in one species and is not one.
Putting the paper back inside a battle pic (BattlePics, below) needs the
pic's pixels, and a LOVE Image does not hand them back -- so the pic is drawn
into a canvas of its own size and the canvas is read. `newCanvas` takes the
SURFACE's dpi scale when it is not told otherwise, `conf.lua` turns highdpi
on for Android and iOS, and Android's display density is routinely 2.75. So
`newCanvas(56, 56)` allocated a 154x154 texture there, the pic was magnified
into it, and the readback came back at the magnified size. The rebuilt pic
was 2.75x the artwork, the engine's pics layer drew it 1:1 because it trusts
`getWidth()`, and the mon stood on its tile nearly three times too big.
Only a pic with an enclosed hole in it is rebuilt at all -- the rest are
handed straight back untouched -- which is why it hit some species and not
others, and why it never showed on desktop, where the dpi scale is already 1.
The readback now asks for one texel per pic pixel, the way the engine's own
`PixelCanvas` does for the same reason. The animated-tile atlas readback took
the same fix: on a phone it would have come back magnified too, and every
tile coordinate in it counts in eights from the top-left.
- **Battle pics were see-through, and it took a back sprite on a tiled floor
to make it obvious.** Gen 1 pics are two-bit art whose lightest shade is
white, and the decoded PNGs key that shade to alpha 0 -- which cost nothing
when the field behind them was white too. Over a route, every belly, every
eye white and every highlight is a hole with the world showing through, and
the mon reads as a stencil.
`BattlePics` exists to put that paper back and, as written, put none of it
back. It flood-filled the outside from the border and filled what the flood
could not reach, which is exact and, on this game's art, empty: a Gen 1
figure is an open drawing, and its belly walks out to the border through the
gap between its legs. Read across all 305 of the game's battle pics, that
rule finds an enclosed hole in exactly none of them.
The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
OWN BOUNDING BOX, and at three of them -- left, right and top. The bottom is
closed, because it is not a side the background is behind, it is where the
drawing was CUT. A pic is bottom-aligned in its slot with all the margin at
the top, so a mon's lowest row is the last row it was given and everything
below the belly simply stops. Treat that cut as open and the background
pours up inside the figure, which is the channel of world that used to show
through a Clefairy.
That is exact rather than a heuristic: nothing is filled because of what
surrounds it, only because the background provably cannot reach it. Which is
why it needs no idea whether it is holding a front pic or a back one -- the
sky between a pair of ears reaches the top edge and stays sky, the gap
between a body and a raised tail reaches the side and stays gap, the belly
reaches neither and is paper. The silhouette is untouched, so the mon still
cuts cleanly against the world.
It replaces the border flood outright rather than sitting beside it, since
anything the border could not reach the box edges cannot reach either.
The bottom edge needs one more distinction, because two different things
meet the underside of a figure. A DRAIN is where the drawing ran out -- a
belly whose white carries on down until the artist stopped, leaking out
through the inch between a body and a leg -- and is sealed. A MOUTH is the
space between two legs, background that happens to be enclosed on three
sides, and is left open so the world shows through a trainer's stride.
Width tells them apart, and on this game's art it is not a close call.
Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
(Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
Nothing lands between 4 and 10, so the cut is taken at 6 with room either
side rather than tuned to one sprite. Apart from that number the rule stays
exact.
Front pics come back untouched, and not by being special-cased: they are
near-solid silhouettes with almost nothing inside them to fill, so their own
shape is what says so.
Both mons were affected -- the cards in the arena as much as anything -- so
this lands wherever a battle pic is drawn over the world, not just under
BACK SPRITES.
- **The pinned back pic was lit at noon while the world behind it was not.**
Everything standing in the arena goes through the voxel shader, and that
shader multiplies by the hour's tint, so at dusk the diorama warms and at
night it goes blue -- the two mons' cards included, because they are drawn
in the same pass as the ground they stand on. A back pic pinned to the menu
is not in that pass; it is a flat blit over the finished shot, and it stayed
bright over a midnight route.
The same tint is now applied to that one draw, by multiplying every colour
the pics layer sets on its way past -- so the alpha, the faint slide's fade
and the damage blink all compose with it instead of being overwritten. What
it does not get is the sun: the cards are shadow-mapped and a pic pinned to
the menu has no position in the scene to be shadowed at, so it carries the
hour and not the weather.
### Added
- **The hour reaches the FLAT world too, not just the diorama.** DAYTIME drove
the 3D pass through the voxel shader's own tint uniform -- a uniform the 2D
tile path never runs -- so with VOXEL off, the same evening that fell on the
diorama left the flat world at permanent noon. One clock, two worlds, one of
them ignoring it. Outdoor maps now get the same multiply, painted as one
rectangle over the composited world.
The whole difficulty is WHERE, and it is worth writing down. Not on the world
canvas: in a colorized mode that canvas is grayscale art and the blit that
puts it on screen runs it through the palette shader, which classifies each
pixel into a shade BY ITS RED CHANNEL -- multiply a night blue over it first
and every pixel lands in the wrong bucket, so the world does not darken, it
changes colour. Not over the finished frame either, or the dialog boxes and
menus darken along with the world they are held up in front of, which is the
same reason the tilt-shift blur is a `worldPresent` and not a `present`.
Which leaves the instant between the world blit and the UI blit, and the
engine has no seam there -- `worldPresent` only runs when a PIPELINE produced
the world, which in flat mode is precisely what did not happen. So
`Renderer:endFrame` is wrapped and the UI canvas's own draw is watched for:
`blit` passes the canvas it is compositing as the first argument, so the
first draw of `Renderer.canvas` IS the boundary, by identity rather than by
counting. The shader and scissor that call arrives under belong to the UI
blit already in progress, so both are put aside for the rectangle and handed
straight back.
Skipped entirely when a pipeline drew the frame (it tinted itself, and twice
is wrong), indoors (a room has no sky to take its light from), and at midday
(a multiply by white) -- so a game with the clock at DAY issues not one extra
call.
### Changed
- **FULL no longer takes the two battle rows off the menu.** It still owns the
rows that describe the LOOK -- the wireframe, the horizon bend, the blur, the
hour -- because it is a preset for the diorama and a row that no longer
decides anything is worse than no row. 3D-BTL and BACK SPRITES are not that:
one decides what a fight is drawn OVER and the other how it is framed.
FULL still SETS both on arrival; it does not hold them, and leaving them
reachable is the difference between a preset and a lock.
This makes `stagedBattles()` honest as a side effect. It used to answer yes
under FULL as well, on the grounds that FULL owned the 3D-BTL row and
switched it on -- safe only while the row was hidden. With the row reachable
from inside FULL, that clause would have claimed staged battles for a preset
the player had just switched them off inside, pinning BATTLE LAYOUT to OG for
a fight that never gets staged. The row is the only thing that decides now,
which is what `OverworldBattle.begin` and `wantsFront` already believed.
- **TILT and GBC FX are off the OPTIONS menu entirely while this mod is
installed.** Both fight the diorama and both were already half-taken: the
mode's own key forces them off on every press, and the registry switches
TILT off whenever a world pipeline takes the pass. What was left was two
rows a player could set and watch get reverted -- TILT being the flat fake
of what this mode does for real, and GBC FX a full-screen present pass over
the top of the whole thing.
Dropped AND held at zero, which is the part that matters: hiding a live
setting is a trap, because a save written before the mod was installed can
carry TILT 3 and a row that is not there cannot turn it back off. Pinned
wherever the value could arrive from -- the menu opening, a save being
loaded or begun -- so there is no route by which either is on and
unreachable. Uninstalling the mod puts both rows back, at whatever they were
last set to.
- **The battle's text box and menus are frosted glass, like the HUDs.** The
HUD blocks got panels because black glyphs on grass are not readable. The box
at the bottom had the opposite problem and the same cause: it is drawn as an
opaque white slab with a black border, which was the field's own colour back
when the field was white and is a sheet of paper laid over the bottom third
of the diorama now that it is not.
It gets exactly what the HUDs get -- the world behind it, blurred to frosted
glass and laid back down translucent, at the same frost and the same tint --
and it is measured into the same brightness verdict, so the ink over the menu
flips white with the ink over the HUDs rather than against it. Only the FILL
is taken away: the border, the text, the cursor and the down arrow are the
engine's own glyphs in their own places. The move menu's TYPE/PP box and
Mimic's copy menu get their own panels, trimmed to the rows above the box
below them so no pixel is frosted twice.
## 1.2.1
### Fixed
- **On Android the sky went black below its first couple of bands.** A hard-edged
band of black ran from partway down the gradient to the horizon point, with the
moon still hanging correctly inside it. Desktop was unaffected.
What gave it away is that the same colour reached the screen by two routes and
only one of them was wrong. The haze filling the void UNDER the horizon is the
sky's palest band, and it is delivered by `love.graphics.clear` -- it landed
correctly. The bottom of the sky above it is that same band delivered by the
shader, and it was black. So the palette was not reaching the fragment shader,
and nothing was wrong with the palette, the layout or the camera.
The bands went in as `uniform vec3 bands[8]`, filled from Lua and read through
a loop counter, and on Android's GLSL ES the tail of that array arrived as
zero -- which is black. The likeliest reason is the fragment uniform budget:
ES 2.0 only guarantees sixteen uniform VECTORS, and eight band slots plus the
twilight glow plus LOVE's own built-ins is over it. A driver that truncates a
partly-filled array, or one that reflects `bands[0]` and nothing after it,
fails identically -- so the fix removes the whole class rather than the one
cause.
The bands are a one-texel-per-band TEXTURE now, sampled nearest, with the
band index clamped against the ramp's width. One texture unit replaces eight
uniform vectors, there is no array to index and no budget to overrun, and a
sample past the last band lands on the last band instead of on nothing. It is
still a palette and not a picture -- one texel per band on a single row -- so
the sky is still computed per pixel at the size it is displayed at, with
nothing resampled and nothing baked.
Also gone with it: `clamp(x, 0.0, 0.999999)`, which rounds its bound to 1.0 at
mediump -- the fragment default on GLSL ES -- and would have indexed one past
the last band on the sky's bottom row for the same black result.
## 1.1.1
### Fixed
@@ -16,6 +261,166 @@
### Added
- **A gradient sky behind the diorama, on every `VOXEL` rung.** The void behind
the world used to be a black plate at every rung but the top, where it became
one flat blue -- enough while that void was a sliver, and a wall of paint once
the horizon came into frame.
It is the 8-bit skybox recipe now: four blues painted as flat horizontal bands,
deepest overhead and palest at the bottom, with a CHECKERBOARD of the next band
dithered into the bottom 40% of each one. Alternating two colours on a pixel
grid is how a machine with four to a palette got a fifth, sixth and seventh out
of them, and it is what keeps four bands reading as a gradient rather than as
four stripes. Every channel of the palette is a multiple of 8 -- where a
five-bit GBC channel lands -- so no colour in it is one the hardware could not
have shown. No clouds, nothing moving.
Where the bands END is the camera's own answer. At `75` the ground plane's
vanishing line is genuinely in frame -- projected through the same matrix the
geometry is drawn with -- and the pale end meets it. At the steeper rungs that
line is above the top edge, and what shows up there is the ground running OUT
past the map edge instead, so the bands take a fixed slice of the frame and the
haze fills the rest. One sky across the whole ladder either way.
**Nothing is resampled**, which is why it is drawn the way it is: no baked
160x144 image scaled up to the window, no downsized buffer blown back up, no
texture at all. One rectangle through a shader answers every pixel from its own
canvas coordinate, so a pixel of sky is computed at the size it is displayed
at and there is nothing for a filter to soften. The band edges and the dither
cells are measured in the pass's own pixels-per-world-pixel, handed in fresh
every frame -- so a `ZOOM` keypress is reflected in the frame that follows it,
with nothing cached at the old scale, and the sky's grid is the same grid the
world's own texels sit on.
The palette goes through the display-mode transform like every other palette in
this mod, so GRAY gets four greys and CLASSIC four greens. Below the bands the
void is filled with the palest of them -- which is also what the bottom band
ends on -- so the join has no seam, and a driver that cannot compile the shader
gets flat bands and a logged line rather than a wrong sky.
The overworld only. A battle is a staged shot whose placed camera has the
horizon above the frame, so the arena keeps exactly the flat sky it had.
- **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`,
`DUSK`, `DAWN`, `SYNC`, `CYCLE`. One twenty-minute clock underneath all of
them -- ten minutes of sun, ten of moon -- where the four named settings
are PINS on that dial (noon, mid-night, sunset, sunrise), `CYCLE` lets it
run, picking up from whichever pin or SYNC sky the player was just looking
at, and `SYNC` -- the DEFAULT -- lays the machine's own clock onto the
dial: local noon is the DAY pin, midnight is NIGHT, six and eighteen the
twilights, an hour of the real day is fifty seconds of dial. Everything is
a pure function of the clock, so the pinned DUSK is exactly the running
cycle stopped at sunset. While **VOXEL** sits on `FULL` the DAYTIME row is
HELD at `SYNC` and taken off the menu with the other rows the preset owns
(DayNight.forceSync, enforced from the preset, the rows hook and the
manager's options_changed -- the same three places BATTLE LAYOUT's pin
lives): the full diorama runs on the real sky.
**The sun and the moon are in the sky**, and their positions are honest:
the disc is the light's own direction projected through the same matrix the
geometry is drawn with, so it stands over the point on the horizon its
shadows point away from, at every pitch, window shape and zoom. The sun's
noon is this mod's existing sun to the digit -- southeast, 45 degrees up,
overhead behind the north-facing camera and correctly out of frame -- and
its arc swings north at both ends, so the disc stands IN frame through dawn
and dusk, rising half-set on the horizon. The moon arcs the northern sky
all night, due north (screen centre) at mid-night, with scaled crater
cells. Both are cell art on the sky's own dither grid, sized by the frame
(a celestial body's apparent size is an angle, so zooming the ground does
not swell it), and both are SCISSORED to the sky's region: the horizon
point is where a setting body disappears -- it never hangs under the map.
**The sky follows the clock.** Phase palettes -- the daytime blues,
gold-to-violet dawn, a hotter gold-to-indigo dusk, moonlit navy -- six
bands each now, blended along the dial and re-quantised onto the 5-bit
lattice, so every mixed frame is still a colour the hardware could show.
The blends bend through designed WAYPOINTS rather than straight across --
a golden hour on the way into dusk, a violet civil twilight either side of
the night -- because day's blue and dusk's gold are near-complements, and
a straight lerp between complements bottoms out in dishwater grey. Through the twilights a posterised, checker-dithered GLOW
warms the bands around the low sun -- painted light, not an airbrush. The
blends are 75 seconds wide either side of each twilight and the pins land
on their phase palette unmixed.
**The shadows follow the sun and the moon.** The shear every shadow is
thrown by (direction opposite the body's bearing, length its elevation's
cotangent, clamped at twice the caster's height) comes off the clock, the
shadow map's signature carries it, and the light's press fades out over the
last twelve degrees before the horizon -- so sunset hands off to moonrise
through a soft shadowless gap, and moonlight presses at about two-thirds
the sun's weight. The scene shader also multiplies every surface by the
hour's tint: neutral at noon, warm at the twilights, dim blue at night.
**Outdoors only**, by the same `Map.isOutdoor` test the sky already rests
on: indoors keeps the noon rig, the neutral tint and no sky -- a cave at
midnight is exactly as dark as a cave at noon. Viridian Forest is the case
between, a CANOPY map (DayNight.CANOPY): there is no sky to paint and no
sun to see, so the shadow rig stays the mod's fixed noon light -- all that
ever filtered through the leaves -- but night still FALLS in a forest, so
of everything the clock does, exactly one thing reaches it: the hour's
tint, in free-roam and staged battles alike. A battle staged on an
outdoor map fights under the hour: the night sky behind the arena, the
tint on the mons, the sunset taking the arena's shadows with it; an indoor
arena is untouched. The engine's own `world.tod` hook is answered
(`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
period ride this clock for free.
**The clock rides the save slot.** On the engine's `save.writing` event the
cycle's time is written into the mod's own save-file bucket
(`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A
save with no clock in it starts at day.
- **Window glass.** The panes in the overworld art -- the framed squares on
building fronts, the small lights in doors -- are found by SHAPE in the
tileset image (a black border row, four or five black-flanked glass rows,
a closing border), at pixel granularity because the door's pane straddles
a 2x2 tile block. No tile ids are hardcoded: a conversion that draws its
own windows in the same idiom gets glass for free. The scan yields a mask
texture aligned to the tileset atlas, which the scene shader samples with
the same coordinates the terrain does -- so the effect lands on any wall,
at any angle, in free-roam and staged battles alike, with no geometry
work.
By day a thin glint crosses the panes WHILE THE VIEW MOVES: the sweep's
phase is fed by the camera's own travel and its strength fades out within
a beat of standing still -- a reflection is something the viewpoint does,
so still camera means still glass. The sweep pattern lives in the pane's
OWN texels, not the screen's: a screen-anchored pattern has the world
sliding through it at zoom speed whenever the camera pans, which strobed
(worst walking against the sweep); anchored to the glass, panning moves
nothing and a step advances the glint a fraction of a texel, the same in
every direction. It lifts the texels toward sky-white and leaves the
shine art visible through it. The mask is consulted only by meshes
textured from the tileset atlas (Voxel3D.glass), never by sprite sheets,
whose coordinates would land on the panes' atlas positions by accident.
After dark the panes are LIT: the texel's own pattern carried into a warm
lamp colour, replacing the shaded answer entirely -- a lit window ignores
the sun, every shadow and the hour's tint, exactly as a window with a lamp
behind it does. The lamps follow the clock (DayNight.windowLight): on
through dusk, full all night, mostly out by dawn, and never lit indoors.
- **A fade out of a battle, where there used to be a hard cut.** The engine
wipes INTO a fight with one of the original's eight transitions and cuts
straight out of it: `BattleState:finish` pops itself and the map is simply
there on the next frame. Between a white field and a tile map the original got
away with that; between a placed camera looking across an arena and a diorama
looking down on a walking player it reads as a glitch. The battle now fades to
black, closes behind it, and the map fades up out of it -- twelve frames each
way, registered as a `voxel_battle_exit` transitions record so the timing is
retunable in data like the wipes it answers.
Only while voxel mode is on, and then for EVERY battle, including one that
found no arena and drew on the flat battle screen: what is being smoothed over
is the return to the map, and the map is a diorama either way. With the mode
off, the vanilla cut is untouched.
One black rectangle over the FINISHED composite does the fading, so the world,
the letterbox bars and the battle's own text box all darken by the same amount
-- the renderer's existing warp-fade overlay is painted between the world and
the UI, which would have left the text box bright over the black. A blackout's
own warp fade or an evolution prompt still owns the way out when it takes the
screen: the fade stops at the cut rather than fading in over the top of it.
- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
puts the whole mode in its intended state -- the 35-degree camera, the
miniature blur at maximum, the horizon flat, the view fitted, and battles
@@ -41,8 +446,77 @@
Pokemon go solid white for those frames, silhouette and all, and nothing
else in the frame moves.
- **A scripted battle cut straight in with no transition** (an ENGINE seam,
fixed in `src/script/Commands.lua` rather than in this mod): the rival in
Oak's lab, and every `start_battle` script, pushed the BattleState bare --
no flash, no wipe, the theme starting late -- where the original wipes
into scripted fights like any other. `start_battle` now routes through the
overworld's own `pushBattle`, which is also the path this mod wraps, so a
scripted fight gets its arena staged and the cast culled BEFORE the wipe
instead of catching up behind it. A battle scripted with no overworld
under it still starts bare, and no music plays twice (BattleState's own
start is a same-song no-op).
- **A standing figure's shadow detached from its feet under a low sun.** The
shadow compare forgives `slack` world pixels so lit ground does not acne
against its own texels, and that same forgiveness lit the first `slack` of
every cast shadow -- so the shadow started a bias-width away from the feet,
further the lower the sun reached (the classic peter-panning, invisible at
the old fixed 45 degrees and plain at a day/night golden hour or under the
moon). Sprite cards -- characters, authored figures, flowers, battle mons
-- are now drawn into the shadow map snugged TOWARD the sun along their
own ray (`ShadowMap.snug`): moving along the ray changes nothing about
where a shadow falls, but storing the card shallower takes three quarters
of the forgiveness back for the shadow it throws -- and for nothing else:
no terrain moved, so the acne margin is untouched where it matters. The
obligation that comes with it: every snugged caster's LIT draw hands the
same snugged transform to its own shadow lookup (Voxel3D.draw's
`sunModel`), so stored and lookup agree exactly and the compare keeps its
full margin -- read un-snugged, the missing nine tenths showed up as
diagonal moire bands crawling across every sprite. The shadow root lands
back under the feet at every hour.
### Changed
- **Under `VOID FILL: TREES` the border wall is modelled trees or nothing.**
Only the first block past the map body gets carved into round trunks and
canopies; the two blocks past that were too far out to be worth the quads,
so they fell through to the mesher's plain box and came out as a flat-topped
slab of tree ART sitting beside the modelled forest -- a painted-on plateau,
and the more obvious the lower the camera got. Rather than pay to carve
hulls nobody walks near, the wall now simply STOPS where the carving does:
`Structures` does not build the ring past that distance (the same "nothing
out there" `BLACK` already produces), and the mesher drops any cell inside
it the 2x2 canopy grouping could not claim, so no strip of boxes survives at
a corner. `WATER` and every indoor border are untouched -- a flat sheet of
water is what water looks like from above anyway.
- **The two HP boxes snap to the window's edges during a staged battle.** The
battle screen is 160x144 in the middle of the window and the world is the
whole of it, which left both HUD blocks huddled together in the middle of the
frame with map showing on either side of them -- a Game Boy screenshot pasted
over a diorama rather than the diorama's own furniture. The foe's block now
sits against the left edge and the player's against the right, on the same
frosted glass, with the same tiles at the same size on the same rows. The
pokeball rows and the safari ball count travel with the block whose rows they
share. On a window shaped like the GB screen there is nowhere to go and
nothing moves.
The engine draws them into the 160x144 canvas, which clips at its own edges,
so the layer is rendered to a texture and composited into the world image --
the one surface here that covers the whole window. A driver that cannot do
that falls back to the HUD in the frame rather than to no HUD.
- **`BATTLE LAYOUT` is pinned to `OG` while battles are staged on the map**, and
the row comes off the OPTIONS menu with the rows `FULL` owns. The staged shot
is composed in the GB's own frame -- the arena camera is solved to put a cell
under each pic's feet, and the HUD rects and the intercepted background fill
are measured there too -- and `WIDE` re-lays that screen out on a 304x144
surface, moving every one of them. Set rather than worked around, on every
route in: the options row, hotkey `8`, the mod manager's page, `FULL`'s
preset, and a save that arrived with `WIDE` already on. Switching `3D-BTL`
off hands the row back with `WIDE` selectable again.
- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
a display-mode cycler -- it should change the camera and nothing else --
and `FULL` reaches in and rewrites four other settings. Landing on it
+13 -35
View File
@@ -34,43 +34,21 @@ menu.
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
| 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 **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 |
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
## Where a battle is staged
Two of the engine's own rows are taken away while this mod is installed:
**TILT**, which is the flat fake of what this mode does for real, and **GBC
FX**, a full-screen present pass over the top of the diorama. Both are held at
off rather than merely hidden — a row that is not there cannot switch off a
value an older save arrived with. Uninstall and both come back, at whatever
they were last set to.
The mod looks for the nearest clearing shaped like this, where every `x` is
a cell the player could walk onto — the two mons three cells apart down the
middle, with a one-cell apron so the camera looks across floor rather than
into a wall:
```
x x x
x O x O the enemy's mon
x x x
x x x
x P x P your mon
x x x
```
Indoors, in a corridor or in a cave there is often no room for that, so the
search relaxes to the same three-cell gap with the apron given up:
```
O
x
x
P
```
If neither will fit — and on a map with no open ground at all, or on a
machine with no depth-buffer support — the battle draws exactly the way it
always did. Water counts as open ground while you are surfing and not
otherwise, and warp tiles never count, so a fight is never framed in a
doorway.
Two of those keys are taken off the engine: `3` was **TILT** and `5` was
**GBC FX**. Neither is reachable by key while this mod is enabled, and both
are still on the OPTIONS menu. Pressing `3` also switches both off — they
fight the diorama, and it is the way back from having left one on.
Everything the battle screen draws as a box — the two HUD blocks, the text
box and the menus over it — sits on frosted glass rather than on the white
field it used to have behind it: the world underneath, blurred and laid back
down translucent, with the ink flipping white where the ground it lands on is
dark. Nothing the engine draws inside a box moves; only the paper is gone.
+11 -1
View File
@@ -39,7 +39,17 @@
return {
-- ------- routes
["ROUTE_1"] = { x = 0, y = 16, shape = "wide" },
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
-- wide shape only fits in the western connection border, which staged every
-- fight at the edge of the world instead of on the road.
--
-- Of the seventeen spots the route has outside that border, fourteen are
-- this one mid-route clearing and the other three bury the near mon behind
-- a hedge -- which the clearance test passes, since it measures terrain
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
+442 -197
View File
@@ -70,6 +70,21 @@
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- And `prop_bg` (not a class either): which GB shades count as BACKGROUND
-- when a pinned per-pixel prop is cut out, for the drawings whose own body
-- reaches the edge of their bounding box and so vote themselves away.
-- Keyed by tile, because two props sharing an atlas can want opposite
-- answers on the same shade.
--
-- And it may carry `figures` (not a class either): hand-drawn pixel masks
-- that lift a FIGURE PAINTED INTO furniture off it and stand it up as a
-- standee, leaving the furniture its own geometry. A pin resolves a
-- whole 8x8 tile, so it can never separate two things that share one --
-- and nothing automatic can either when the figure has no background
-- margin to flood from and wears the same shades as what it sits on.
-- The Pokemon Center's seated man is the case; see POKECENTER below for
-- the format.
--
-- 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
@@ -87,6 +102,9 @@ return {
fence = 10,
sign = 12,
wall = 16,
-- masonry drawn two courses tall (the Indigo Plateau's rim, the
-- badge-check gates): as tall as a statue on its plinth
cliff = 32,
tree = 16,
roof = 28,
bed = 7,
@@ -694,22 +712,17 @@ return {
-- bodies so they stand ON them) and the PC (66/70/82/86), which
-- stands on its pinned desk
billboard = { 58, 59, 66, 70, 74, 75, 82, 86 },
-- Why the man on the couch rides the top face instead of standing
-- up. He is drawn across two tile rows (skin pixels appear in
-- rows 8 and 9 and stop dead at the row 9/10 seam), and folding
-- two rows upright requires both to share a class, which makes the
-- box two tiles deep -- and a fully folded box repeats its north
-- row across its whole top face. So every upright arrangement
-- puts his head on screen two or three times: as a 16px seat-back
-- his head lands on the front AND twice on the top; as a 32px
-- bookcase he becomes a cabinet taller than the room's walls.
-- Dropping the seat to floor level to clear his face does not help
-- either, it only moves which copy you see. He also cannot be a
-- standee: the drawing has no floor margin, so all three non-black
-- shades touch the cluster rim and the mask drains 307 of its 420
-- interior pixels -- 46% of him even segmented alone, because his
-- skin is the same light shade as the couch behind him. Riding
-- the top face is the one arrangement that draws him exactly once.
-- The man on the couch, cut out by hand and stood up (see `figures`
-- below). Nothing automatic reaches him. A class pin resolves a
-- whole 8x8 tile and he SHARES his tiles with the couch, so pinning
-- them stands the furniture up with him; riding the couch's top
-- face (what this did before) draws him lying flat on the cushion;
-- and he cannot be segmented into a standee either, because the
-- drawing has no background margin for a flood to enter by and his
-- skin is the same light shade as the couch -- the mask drained 307
-- of his 420 interior pixels, 46% of him, even segmented alone.
-- An authored mask is the only thing that can tell a man from the
-- sofa he is painted into, so that is what `figures` carries.
-- the PC's desk body, which the PC stands on
table = { 9, 88 },
-- the potted plants: bush (32/33/48/49) over pot (34/35/50/51,
@@ -721,6 +734,82 @@ return {
-- them; the standee reads darker than the flat art, which is the
-- accepted trade for a real plant silhouette
prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
-- ...but the POTS were draining outright. The plant pair is drawn
-- as one 4x4-tile block and the pot's olive base is flush on that
-- block's bottom row, so the background vote -- which reads the
-- shades touching the drawing's own bounding box -- came back with
-- "dark" in it, and every dark pixel in the plant left with the
-- floor. The pots rendered as hollow black frames around one or
-- two surviving pixels while the flat art has solid olive bodies.
--
-- So name the background outright for these eight tiles: the floor
-- IS light and white here, and nothing else is. That restores 152
-- pixels of the block (55% -> 69% of it kept) and cannot move any
-- other prop, which matters -- the same call tileset-wide would pull
-- the dark wall band into the healing consoles' screens and strip
-- three pixels off the PC, because those two want the opposite
-- answer on the very same shades.
prop_bg = {
{ tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
shades = { "light", "white" } },
},
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and
-- spills out of it in BOTH directions, which is why he needs three
-- tile columns:
--
-- 36 / 52 the couch's west arm. 36's two RIGHTMOST columns are
-- the back of his head; 52 is the plain arm, and is also
-- what 36 wears once his hair comes off it
-- 37 / 53 him: head, then face and body
-- 57 / 60 the floor east of the couch, which his hair and his
-- foot overhang. 1 and 26 are those same two floor
-- tiles as the artist drew them WITHOUT him -- 8 and 5
-- pixels apart respectively -- so lifting him off is
-- lossless there
--
-- `pixels` is the hand-drawn line between the man and the furniture;
-- `under` is what each tile wears once he is lifted off it. The
-- couch keeps its polygonal box and he stands on top of it.
--
-- The mask keeps ALL of 37/53's column 7, the couch's east edge.
-- Where that column is drawn dark it is the couch's own rule -- but
-- it is also where his hair crosses the tile seam, so dropping it
-- floats the overhang free of his head. And where it is drawn WHITE
-- it is the right side of his face, so dropping it opens a slit down
-- his cheek (rows 5-8, which is exactly what the first cut did).
-- Keeping the rule twice costs nothing: tile 39 redraws it on the
-- couch beneath him either way. Repainting 36 as 52 does cost one
-- row -- 36's top trim band, which 52 does not carry -- and that is
-- the accepted price for not leaving a second copy of his head lying
-- on the arm. The background corners around his head and the
-- cushion wedge under his legs are the only pixels given back.
figures = {
{
w = 3,
tiles = { 36, 37, 57,
52, 53, 60 },
under = { 52, 39, 1,
52, 39, 26 },
pixels = {
"..........XXXXX.........",
"........XXXXXXXX........",
".......XXXXXXXXXX.......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"........XXXXXXXXX.......",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXXX.......",
".........XXXXXXXXX......",
"...........XXXXXX.......",
"..............XX........",
},
},
},
},
-- The Poke Marts (one 4x4 layout serves every city; the tileset
@@ -731,12 +820,61 @@ return {
-- boxed the two free-standing shelf racks into one 4-tile-deep
-- monolith.
MART = {
-- the wall band stays one 16px face: the trim and case tops
-- (40/90/91), the SALE cases (78/79) with their feet (23/29), and
-- the glass drink fridges (44-47) with their feet (62/63) -- all
-- drawn built INTO the back wall, exactly like the Center's
-- healing consoles
wall = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 78, 79, 90, 91 },
-- THE BACK WALL. It is drawn FOUR tile rows tall -- trim (40, or
-- the fridge tops 90/91), the SALE signs (78/79) or glass upper
-- (44/45), the black display niche (76/77) or glass lower (46/47),
-- and the cases' base and goods (23/29, 62/63) -- which is 32px of
-- artwork depicting ONE wall, not four things at four depths.
--
-- Pinned `wall` that is exactly what it became: every row got its
-- own 16px box marching north, so the only face you ever saw was
-- the southmost row (the cases' base), the signs and the niche hid
-- behind it, and the trim ended up lying flat as a gold shelf on
-- the roofs of the boxes behind. Laid out in depth instead of
-- stacked up.
--
-- `bookcase` is the class that collapses a tall drawing onto a
-- one-cell-deep box at its real drawn height, so the run of four
-- rows becomes a single 32px wall: bands from the south are base,
-- niche, sign, trim, its top face wears the trim, and the three
-- rows behind become hidden floor. Same treatment the shelf racks
-- below already get -- a Mart's back wall IS a wall of display
-- cases, and the geometry does not care which we call it.
bookcase = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 76, 77, 78, 79,
90, 91,
-- the free-standing shelf racks: TALL drawings, not
-- deep ones -- each rank collapses onto a
-- one-cell-deep shelf at its drawn height (the
-- Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as
-- its top display; 68/69/71 and 84/85/87 the goods
-- rows below
64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- The wall's own tiles are reused elsewhere, and the back wall is
-- the one place they are drawn on the map's TOP EDGE. So `bookcase`
-- is their default and this hands every other use back to `wall`,
-- which is what all of them were before: 40 is also the clerk's
-- booth back panel (under the shelf rows 80/81 or under itself), and
-- 40/76/77/90/91 all recur in INDIGO_PLATEAU_LOBBY, the ninth map on
-- this id, where they draw the hall and the lift bank.
--
-- NEVER put 0 in an `above` set. Map:tileAt does not answer nil off
-- the top of a map -- it border-extends, so row 0 reads the map's
-- borderBlock, which indoors is the black void block 0. Listing 0
-- to catch the Lobby's void-backed 90/91 fired the rule on every
-- Mart's fridge row as well: row 0 dropped out of the bookcase run,
-- the fridges came out 24px against the SALE cases' 32px, and their
-- trim row stood as a separate box BEHIND the wall instead of on top
-- of it. Nothing can separate those two cases from above -- both
-- see void -- so the Lobby (already out of scope here) gets the
-- bookcase reading too, and the Marts come out right.
when_above = {
[40] = { { above = { 40, 80, 81, 83, 90, 91 }, class = "wall" } },
[76] = { { above = { 74, 75 }, class = "wall" } },
[77] = { { above = { 74, 75 }, class = "wall" } },
[90] = { { above = { 17 }, class = "wall" } },
[91] = { { above = { 27 }, class = "wall" } },
},
-- the clerk's counter, half a cell high like every service
-- counter. It is a C wrapping the alcove the clerk stands in: the
-- south arm's drawn front (24/25) under its top band (8/56), the
@@ -746,43 +884,66 @@ return {
-- cash register, pinned it `billboard`, and got a bare black
-- outline standing on the counter with the tile's own art replaced
-- by its neighbour's.
counter = { 8, 16, 24, 25, 41, 56, 89 },
-- The CASH REGISTER (14/15 over 30/31), a keypad and a curl of
-- receipt paper drawn face-on across two tile rows in the middle of
-- the counter's east arm. Pinned `counter` it was just paint on
-- the work surface. In the `billboard` pool it is a standing
-- per-pixel cutout ten voxels deep -- the flower-and-vase treatment
-- of Red's house, but with a machine's body rather than a stem --
-- and the support rule lifts it onto the counter automatically,
-- because the tile drawn directly BELOW it (16/41) is a pinned 8px
-- box. Its own pool, so the counter can never absorb it. The art
-- has a clean light margin on three sides and a black outline all
-- round, so the shade flood drains the work surface away and leaves
-- the machine whole.
--
-- `console`, not `billboard`, for the two vertical black rules the
-- COUNTER draws down the outer edges of those same tiles (columns
-- 0 and 15 of the pair, full height). They are the counter's own
-- edging, not the register, but black always survives the shade
-- flood, so the billboard pool extruded them too and the machine
-- stood flanked by a pair of tall black slabs. A white column
-- separates them from the register body, so they are their own
-- connected components -- and `console` carries the same
-- one-object contract `cutout` has, which keeps the largest
-- drawing and drops them, at ten voxels of body rather than one.
console = { 14, 15, 30, 31 },
-- the free-standing shelf racks: TALL drawings, not deep ones --
-- each rank collapses onto a one-cell-deep shelf at its drawn
-- height (the Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as its top
-- display; 68/69/71 and 84/85/87 the goods rows below
bookcase = { 64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- ...and the register's own two tile rows (14/15 over 30/31) are
-- part of that same work surface now that the machine has been cut
-- off them as a sprite -- see `figures` below.
counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 },
-- Left to the derived default on purpose: the floor checker and
-- its shadowed variants (1/11/17/26/27/54) and the exit mat
-- (12/28) all sit in cells the ROM marks walkable, so the cell
-- rule lays them flat unaided; 76/77 is the SALE case's black
-- interior, which the volume path recesses half a course into the
-- band -- a dark display niche, which is what it is drawn as.
-- rule lays them flat unaided. (76/77, the SALE case's black
-- interior, used to be left to the volume path here; it is now the
-- back wall's third band, because a run of four rows has to be
-- contiguous for the wall to collapse into one box at all.)
-- THE CASH REGISTER: a keypad and a curl of receipt paper drawn
-- face-on across two tile rows in the middle of the counter's east
-- arm. Like the Center's seated man it is a FIGURE drawn into the
-- furniture, so it gets the same treatment -- a flat sprite card
-- standing on the counter -- and for the same reason: no class pin
-- can separate it from the counter, because the counter draws its
-- own edging down columns 0 and 15 of the very same tiles.
--
-- That edging is what defeated every automatic reading. Black
-- always survives the shade flood, so the standee pools extruded
-- the two rules along with the machine and it stood flanked by a
-- pair of tall black slabs; `console`'s keep-the-largest-drawing
-- rule dropped them, but only by throwing away the receipt curl
-- too whenever the flood happened to cut it loose. The mask just
-- says where the machine is: columns 2-13, plus the curl climbing
-- to the top right, and the counter keeps its edging and its light
-- work surface.
--
-- `under` is 16/41 twice -- the plain work surface, which already
-- carries the west edging (black over white) and the east (dark
-- over black), so the counter closes up behind the machine with no
-- synthesis at all.
figures = {
{
w = 2,
tiles = { 14, 15,
30, 31 },
under = { 16, 41,
16, 41 },
pixels = {
".........XX.....",
"........XXXX....",
"........XXXXX...",
"........XXXXXX..",
"...XXXXXXXXXXX..",
"..XXXXXXXXXXXX..",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
},
},
},
-- NOT covered: INDIGO_PLATEAU_LOBBY, the ninth map on this id and
-- the only one that is not the 4x4 shop. It draws its own hall,
-- lift bank and rope stanchions from tiles no Mart places, three
@@ -2100,6 +2261,231 @@ return {
-- south-east). Neither map places them; they belong with the
-- wall band above if one ever does.
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
-- TWO courses (32px) for the masonry that ENCLOSES both maps -- the
-- plateau's rim and every wall around the terraces. It is drawn two
-- cells tall, which is exactly the height of a statue on its plinth
-- (a 16px plinth under a 16px standee), and that is the read: you are
-- walking in a walled compound whose wall matches the statues lining
-- it, not a room with a 16px skirting. At one course the rim was a
-- kerb you appeared to look over.
--
-- $03 the striated rock face, $0D/$0F/$0E the League's outer wall
-- (top band, face, base -- the same three tiles draw the League's
-- facade, the walls flanking the avenue and every badge-check gate
-- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its
-- cap. These four groups are the enclosure.
cliff = { 3, 13,
32, 33, 46, 47 },
-- THE GATE WALLS, stacked rather than laid out in depth. $0D/$0F/$0E
-- (13/15/14 -- top band, face, base) draw the League's facade, the
-- walls flanking the avenue and every badge-check gate down ROUTE_23,
-- as FOUR tile rows: 13 / 15 / 15 / 14. That is 32px of artwork
-- depicting one wall, and at one class per row it built four boxes
-- marching north, so the wall was 32px DEEP -- you saw the southmost
-- row's face and the rest hid behind it.
--
-- `bookcase` collapses the run onto a single one-cell-deep box at its
-- full drawn height: bands from the south are base, face, face, top
-- band, and the two rows behind are vacated. Same mechanism the
-- Mart's back wall uses.
-- ...and the PILASTER stacks with it. $05/$06 is its shaft, and it
-- is drawn only ever inside a pilaster, so it needs no rule. Without
-- this the pale columns stood 16px against a 32px brown wall and
-- their top vertices sat a whole course below the wall's crown.
bookcase = { 14, 15, 5, 6 },
-- Tile 13 is DUAL-USE and needs resolving per position. It is the
-- gate wall's TOP BAND (block $28's first row) and it is also the
-- BASE COURSE under a column of rock face (blocks $18/$1A/$1B, last
-- row). Pinned `bookcase` outright the second use became a one-row
-- rank -- an 8px stub under the rock, 352 of them over both maps.
--
-- ABOVE cannot tell them apart, which is what `when_below` is for.
-- Scanned over both maps through the engine's own tileAt, the tile
-- above a 13 is the rock face $03 for 140 base courses AND for 64
-- gate bands -- so a rule on `above` misfires on those 64 (it did:
-- their runs came out 2 and 3 bands instead of 4). BELOW splits it
-- exactly: the wall's own face $0F sits under the top band and under
-- nothing else, 336 against 352. So 13 defaults to `cliff` and is
-- promoted where the face is drawn beneath it.
--
-- 14 and 15 need no rule: 14 only ever sits under 15, 15 only ever
-- under 13 or 15.
when_below = {
[13] = { { below = { 15 }, class = "bookcase" } },
-- The pilaster's CAP ($15/$16) is the same stone as the statue's
-- plinth top -- the artist drew it twice -- so it too resolves per
-- position. Scanned over both maps: a cap with the shaft $05/$06
-- beneath it is a pilaster (76 of them) and one with the plaque
-- base $30/$31 beneath it is a statue's plinth (47). Default
-- `wall`, promoted here, so the plinth keeps its single course and
-- the statue standing on it still totals the 32px the wall is.
[21] = { { below = { 5 }, class = "bookcase" } },
[22] = { { below = { 6 }, class = "bookcase" } },
},
-- The other two ends of the same pair of drawings, keyed the other
-- way round. A pilaster is capped at BOTH ends, so its lower cap has
-- the shaft ABOVE it (8 placements); and the plaque base $30/$31 is a
-- pilaster's foot when the shaft is above it (68) but a statue's
-- plinth bottom when the cap $15/$16 is (47).
when_above = {
[21] = { { above = { 5 }, class = "bookcase" } },
[22] = { { above = { 6 }, class = "bookcase" } },
[48] = { { above = { 5 }, class = "bookcase" } },
[49] = { { above = { 6 }, class = "bookcase" } },
},
-- The vacated rows behind a collapsed wall take the cell ABOVE the
-- run rather than the default hidden floor: here that is more rock
-- face on ROUTE_23 and the plateau's paving or grass on INDIGO_
-- PLATEAU, so the wall reads as set INTO the terrace instead of
-- standing in front of a trench of synthesized ground.
bookcase_backfill = "above",
-- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
-- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
-- IS the statue's plinth, the artist having drawn the same stone
-- twice. Raising it would carry every statue standee up to 48px and
-- break the very match the cliff height was chosen for.
-- $05/$06 is NOT here: it moved to `bookcase` above, and a tile
-- listed in two groups resolves by whichever `pairs` order wins --
-- half the pilasters kept the shaft as `wall`, which broke the run
-- and left their caps as isolated 8px stubs. One group per tile.
wall = { 21, 22, 48, 49 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
@@ -2845,146 +3231,5 @@ return {
slab = 4, frontEave = 4, ledge = nil,
},
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
wall = { 3,
13, 14, 15,
21, 22, 48, 49, 5, 6,
32, 33, 46, 47 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
}
+17
View File
@@ -42,6 +42,20 @@ local quad = nil -- nil = untried, false = unavailable
-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole
-- texture. Feet on the model origin, so the model matrix only has to say
-- where the mon is standing and how big it is.
--
-- Which puts this card OFF the voxel grid, alone among the meshes in this
-- mode: the rest are built one unit per voxel in their own model space --
-- terrain in world pixels, a character's card in the sprite's own pixels --
-- and the wireframe is the integer planes of that space (see VoxelGrid).
-- One unit here is the whole card, so the only integer plane inside it is
-- x = 0, which is the pic's centre column: a single stray line straight
-- down the middle of every Pokemon and no seams anywhere else.
--
-- The card stays a unit card, because a mon's size on screen is decided by
-- the artwork's own dimensions and the distance it is standing at, and a
-- unit card is what lets one matrix say both. Whoever draws it turns the
-- wireframe off instead (Voxel3D.seams) -- a mesh that is not on the voxel
-- grid does not get a voxel grid drawn on it.
local function unitQuad()
if quad ~= nil then return quad or nil end
local verts = {
@@ -93,8 +107,11 @@ function BattleBillboard.draw(tex, x, y, z, grow)
if grow then w, h = w * grow, h * grow end
if w <= 0 or h <= 0 then return false end
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
-- off the voxel grid, so no wireframe on it (see unitQuad)
Voxel3D.seams(false)
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
BattleBillboard.PULL)
Voxel3D.seams(true)
return true
end
+201
View File
@@ -0,0 +1,201 @@
-- Leaving a battle: the fade the way back to the map never had.
--
-- Going IN is a whole production -- one of the original's eight wipes, picked by
-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a
-- hard cut: BattleState:finish pops itself and the map is simply THERE on the
-- next frame. On the flat battle screen that is a cut between a white field and
-- a tile map, which the original got away with. In this mode it is a cut between
-- a placed camera looking across an arena and a diorama looking down on a
-- walking player, and a jump that big reads as a glitch rather than as an edit.
--
-- So the battle fades out, closes behind the black, and the map fades up out of
-- it. The timing is a transitions record this mod registers rather than a
-- constant in here, so it is retunable in data like the engine's own eight.
--
-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a
-- vanilla battle keeps the cut it always had. While the mode IS on, every battle
-- gets it -- including one that found no arena and drew on the flat battle
-- screen -- because what is being smoothed over is the return to the MAP, and
-- the map is a diorama either way.
--
-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws
-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED
-- composite in a wrap around Renderer:endFrame paints it -- after the world
-- blit, after the letterbox, after the UI blit, which is the only point where a
-- single rect covers everything on screen at once.
--
-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted
-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that
-- borrowed it would darken the arena and leave the battle's text box sitting
-- bright on top of the black -- and the letterbox bars of a flat battle screen,
-- painted by the renderer's clear before any state draws, would not darken at
-- all.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleExit = {}
BattleExit.__index = BattleExit
-- The battle underneath keeps drawing while this is up -- what fades is its own
-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so
-- nothing the battle does can outrun the fade either.
BattleExit.isOpaque = false
-- The registered record's id, and the fallback timing if it is missing (a
-- headless caller, or a total conversion that dropped the namespace). Per HALF,
-- matching the engine's warp fade, so the whole edit is 24 frames.
BattleExit.ID = "voxel_battle_exit"
BattleExit.FRAMES = 12
-- The fade in progress, or nil. Kept here rather than on the state so the
-- endFrame wrap has one place to look and nothing can go stale the frame after
-- the state leaves the stack.
local live = nil
-- How black the composite is this frame: 0 on the battle's last live frame, 1 at
-- the cut, 0 again once the map is up. nil when no fade is running, which is
-- every other frame the game ever draws.
function BattleExit.veil()
if not live then return nil end
-- A fade can be taken off the stack by something that is not the fade: a
-- script or a shot driver popping down to the overworld, a state teardown.
-- Then it is not running, whatever its counter says -- and a veil left behind
-- would black the game out for good, because nothing is going to fade it back
-- in. Checked here rather than trusted, because this is the one place the
-- answer is used. The walk only happens while a fade is live.
local stack = live.game and live.game.stack
local states = stack and stack.states
local onStack = false
for i = #(states or {}), 1, -1 do
if states[i] == live then onStack = true break end
end
if not onStack then live = nil; return nil end
local a = live.t / live.frames
if live.phase == "in" then a = 1 - a end
return math.max(0, math.min(1, a))
end
local function framesFor(game)
local records = game and game.data and game.data.transitions
local record = records and records[BattleExit.ID]
local frames = record and record.frames
if type(frames) == "number" and frames > 0 then return frames end
return BattleExit.FRAMES
end
function BattleExit.new(game, battle, onMidpoint)
return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint,
frames = framesFor(game), t = 0, phase = "out" },
BattleExit)
end
-- Push the fade over the battle it is closing.
function BattleExit.start(battle, onMidpoint)
local game = battle.game
local self = BattleExit.new(game, battle, onMidpoint)
live = self
game.stack:push(self)
return self
end
function BattleExit:update()
self.t = self.t + 1
if self.t < self.frames then return end
self.t = 0
if self.phase == "in" then
live = nil
self.game.stack:pop()
return
end
-- ------- the cut, at full black
--
-- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while
-- this fade is up that is the fade -- so a fade that stayed would eat the
-- battle's own pop and leave the battle running underneath, finished but
-- still on the stack. Popping ourselves hands the top back to the battle so
-- its pop lands on itself.
self.phase = "in"
local stack = self.game.stack
stack:pop()
if self.onMidpoint then self.onMidpoint() end
if stack:top() == self.game.overworld then
stack:push(self) -- and the map comes up out of it
return
end
-- We are not going back to the map after all. Either the battle did not
-- actually leave -- finish() can be a false start, and wanted() mirrors the
-- one the engine has today -- or something else took the screen on the way
-- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns
-- the transition from here, so this one ends at the cut instead of fading in
-- over the top of it. The flag goes back too, so a second finish() that does
-- leave gets its own fade.
live = nil
if self.battle then self.battle.dramaticShapeLeaving = nil end
end
-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a
-- fault, and runnable on this machine. A function on the table rather than an
-- inline call so a driver or a headless test can pin it -- the test harness has
-- no depth buffer, where the honest answer is no on every rung.
function BattleExit.modeOn()
return require("src.render.Pipelines").eligible("voxel") and true or false
end
-- Whether this ending gets the fade.
function BattleExit.wanted(battle)
local game = battle and battle.game
if not (game and game.stack) then return false end
-- finish() is not always the end: an unpaid PAY DAY prints its takings and
-- comes back through here a moment later (BattleState:finish's first branch).
-- Mirrored read-only, so the fade starts on the call that really leaves rather
-- than fading to black and snapping back for one more message.
if battle.payDay and battle.result == "win" then return false end
return BattleExit.modeOn()
end
-- ------- engine seams
--
-- Two wraps, each idempotent so a hot reload cannot stack them.
function BattleExit.install()
local BattleState = require("src.battle.BattleState")
if not BattleState.dramaticShapeExitHook then
local inner = BattleState.finish
-- The one place a battle ends. Wrapped rather than listened for: the
-- battle.ended event is emitted AFTER the pop, and by then the battle
-- screen is gone and there is nothing left to fade out.
function BattleState:finish()
if self.dramaticShapeLeaving or not BattleExit.wanted(self) then
return inner(self)
end
self.dramaticShapeLeaving = true
BattleExit.start(self, function() inner(self) end)
end
BattleState.dramaticShapeExitHook = true
end
local Renderer = require("src.render.Renderer")
if not Renderer.dramaticShapeExitHook then
local inner = Renderer.endFrame
function Renderer:endFrame(zones, worldZones)
inner(self, zones, worldZones)
local a = BattleExit.veil()
if not a or a <= 0 then return end
-- The composite is on the screen by now, in LOVE units, so one rect over
-- the window darkens the world, the letterbox bars, the text box and
-- anything a present pass put on top, all by the same amount. Left to
-- last on purpose: this is a shutter closing on the finished frame, not a
-- layer inside it.
local w, h = love.graphics.getDimensions()
love.graphics.setColor(0, 0, 0, a)
love.graphics.rectangle("fill", 0, 0, w, h)
love.graphics.setColor(1, 1, 1, 1)
end
Renderer.dramaticShapeExitHook = true
end
end
return BattleExit
+64 -5
View File
@@ -201,6 +201,23 @@ local function frostRect(rect, box)
return fx, fy, math.max(1, fw), math.max(1, fh)
end
-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD
-- snapped out to the window's edge has left the GB frame, so it has no GB
-- coordinates to be placed from -- see OverworldBattle.snapRects.
local function frostRectWorld(rect, box)
local kx = frostW / box.pw
local ky = frostH / box.ph
return rect[1] * kx, rect[2] * ky,
math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
end
-- Which of the two the caller's rects are in. One frost buffer, one panel
-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
-- canvas) or world pixels (rects land in the window-resolution world image).
local function mapper(world)
return world and frostRectWorld or frostRect
end
-- ------- the verdict
--
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
@@ -211,11 +228,12 @@ end
-- the tint below then commits both panels to that reading.
local wasDark = false
function BattleHud.verdict(rects, box)
function BattleHud.verdict(rects, box, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local toFrost = mapper(world)
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = frostRect(rect, box)
local fx, fy, fw, fh = toFrost(rect, box)
local v = sampleLuma(key, fx, fy, fw, fh)
if v and (not darkest or v < darkest) then darkest = v end
end
@@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box)
return wasDark
end
-- Draw one HUD panel into the current target, in GB coordinates.
-- Draw one HUD panel into the current target, in that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- The tint always pushes AWAY from the glyph colour that is about to be
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
-- gets darker under white text, a bright one brighter under black text.
function BattleHud.panel(rect, box, dark)
function BattleHud.panel(rect, box, dark, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = frostRect(rect, box)
local fx, fy, fw, fh = mapper(world)(rect, box)
local ok = pcall(function()
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
@@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn)
love.graphics.setShader()
end
-- ------- the whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
-- into the texture rather than composited into the caller's target -- the world
-- image the quads land on is a colour canvas, and a flip pass over it would
-- whiten the terrain behind the glyphs along with them.
local hudLayer = nil
function BattleHud.layerTexture(w, h, dark, fn)
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
hudLayer = canvasOf(w, h, "nearest")
if not hudLayer then return nil end
end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(hudLayer)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
-- flipGlyphs renders fn into its own scratch layer and composites the
-- whitened result into whatever is bound, which is this canvas
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return hudLayer
end
-- The last luminance measured, for the shot driver's log.
function BattleHud.lastLuma()
local best = nil
@@ -344,6 +402,7 @@ function BattleHud.invalidate()
frostW, frostH = 0, 0
luma = {}
wasDark = false
layer, hudLayer = nil, nil
end
return BattleHud
+131 -22
View File
@@ -9,12 +9,56 @@
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
-- hole with the world showing through, and the mon reads as a stencil.
--
-- So the paper is put back, and only where the paper was: the pic is read
-- back once, the transparent region OUTSIDE the figure is flood-filled from
-- the border, and every transparent pixel the flood could not reach -- every
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
-- untouched, so the mon still cuts cleanly against the world; only its
-- insides stop being see-through.
-- So the paper is put back, and only where the paper was. Which pixels those
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
-- the hardware drew the mon's white belly and the white field behind it with
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
-- image says which was which. There is no distinction to recover; there is one
-- to draw.
--
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
-- can reach is background, and whatever it cannot is paper. What makes that
-- work is where the flood is allowed to start.
--
-- Start it at the image border and it fills everything and answers nothing.
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
-- out between two legs and off the bottom of the frame. Run over all 352 of
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
-- it left every mon a stencil, which is the bug this file exists to fix and
-- for a long time did not.
--
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
-- the left, the right and the top are seeded whole. The sky between a pair of
-- ears reaches the top edge and stays sky; the gap between a body and a raised
-- tail reaches the side and stays gap.
--
-- The BOTTOM is the interesting one, because two completely different things
-- meet the underside of a figure and they have to be told apart.
--
-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
-- on down until the artist stopped, leaking to the outside through the inch
-- between a body and a leg. Seal it: what is above it is the mon.
--
-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
-- that happens to be enclosed on three sides. Leave it open: the world
-- should show through the gap in a trainer's stride.
--
-- What separates them is how WIDE the opening is, and on this game's art that
-- is not a close call. Measured along the bottom of every battle pic: the
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
-- taken at 6 with room either side rather than tuned to a single sprite.
--
-- Apart from that one number the rule is exact: no pixel is filled for what
-- surrounds it, only because the background provably cannot get to it. And it
-- needs no idea whether it is holding a front pic, a back one or a trainer --
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
-- and they come back untouched because that is what their own shape says, not
-- because they were special-cased.
--
-- The silhouette is untouched, so the mon still cuts cleanly against the
-- world; only its insides stop being see-through.
--
-- Read back off the GPU rather than off the asset, deliberately. What comes
-- back is the pic the engine actually decided to draw -- species palette,
@@ -44,6 +88,21 @@ local CUT = 0.5
-- its data back, so it is drawn into a canvas of its own size and the canvas
-- is read -- which is also what makes this work for every path that produces
-- a pic, without knowing which one produced this one.
--
-- The canvas is forced to dpiscale = 1, and that is the whole difference
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
-- newImageData reads the magnified copy back at its own PIXEL size. The image
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
-- it trusts getWidth(), and the mon stands on the map nearly three times the
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
-- is already 1 there. Nor did every species, because only a pic with an
-- enclosed hole in it comes back through here at all (see `changed` below) --
-- so a Pidgey came out giant and the mon beside it did not, which is what
-- makes this read as a sprite bug rather than a scale one. See the engine's
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
local function readBack(img)
local w, h = img:getDimensions()
if w <= 0 or h <= 0 then return nil end
@@ -52,7 +111,7 @@ local function readBack(img)
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local data = nil
local ok = pcall(function()
local canvas = love.graphics.newCanvas(w, h)
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("replace", "premultiplied")
@@ -72,32 +131,78 @@ local function readBack(img)
return ok and data or nil
end
-- Mark every transparent pixel reachable from the border. That set is the
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
-- The box the artwork actually occupies, or nil for a pic with no ink in it.
--
-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
-- whole rows and columns of nothing around itself. The bottom of THIS box is
-- the cut the rule below turns on, and the bottom of the image is just empty
-- frame some distance under it.
local function inkBounds(data, w, h)
local x0, y0, x1, y1 = w, h, -1, -1
for y = 0, h - 1 do
for x = 0, w - 1 do
local _, _, _, a = data:getPixel(x, y)
if a > CUT then
if x < x0 then x0 = x end
if x > x1 then x1 = x end
if y < y0 then y0 = y end
if y > y1 then y1 = y end
end
end
end
if x1 < x0 then return nil end
return x0, y0, x1, y1
end
-- The widest opening along the bottom of a figure that still counts as a drain
-- rather than a mouth. See the header for the measurements either side of it.
BattlePics.DRAIN = 6
-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
-- the edges of the artwork's box: the left, the right and the top whole, and
-- along the bottom only those openings wide enough to be background rather
-- than the underside of a figure the drawing ran out of.
--
-- Confined to the box as well as seeded from it, so the empty frame under a
-- short pic cannot walk around a sealed drain and come back up through it.
--
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
-- pixels and a keyed-out background is most of them, which is a deeper call
-- chain than is worth risking for no gain.
local function markOutside(data, w, h)
local function markOutside(data, w, h, x0, y0, x1, y1)
local outside = {}
local stack, top = {}, 0
local function clear(x, y)
local _, _, _, a = data:getPixel(x, y)
return a <= CUT
end
local function push(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return end
if x < x0 or y < y0 or x > x1 or y > y1 then return end
local key = y * w + x
if outside[key] then return end
local _, _, _, a = data:getPixel(x, y)
if a > CUT then return end
if not clear(x, y) then return end
outside[key] = true
top = top + 1
stack[top] = key
end
for x = 0, w - 1 do
push(x, 0)
push(x, h - 1)
for x = x0, x1 do push(x, y0) end
for y = y0, y1 do
push(x0, y)
push(x1, y)
end
for y = 0, h - 1 do
push(0, y)
push(w - 1, y)
-- the bottom, run by run: a wide one is the gap between two legs and lets
-- the world through, a narrow one is where a belly ran out and is sealed
local x = x0
while x <= x1 do
if clear(x, y1) then
local from = x
while x <= x1 and clear(x, y1) do x = x + 1 end
if (x - from) > BattlePics.DRAIN then
for k = from, x - 1 do push(k, y1) end
end
else
x = x + 1
end
end
while top > 0 do
local key = stack[top]
@@ -124,12 +229,16 @@ function BattlePics.filled(img)
local data = readBack(img)
if not data then return end
local w, h = data:getDimensions()
local outside = markOutside(data, w, h)
local x0, y0, x1, y1 = inkBounds(data, w, h)
if not x0 then return end -- a pic with nothing drawn in it
local outside = markOutside(data, w, h, x0, y0, x1, y1)
local fill = BattlePics.FILL
local changed = false
for y = 0, h - 1 do
-- only inside the box: everything beyond it is frame the artist never
-- reached, and filling that would put the mon in a white rectangle
for y = y0, y1 do
local row = y * w
for x = 0, w - 1 do
for x = x0, x1 do
if not outside[row + x] then
local _, _, _, a = data:getPixel(x, y)
if a <= CUT then
+57 -9
View File
@@ -1,4 +1,4 @@
-- Overworld battles: one frame of the arena, as geometry.
-- Overworld battles: one frame of the arena, as geometry.
--
-- The same world the free-roam mode draws, from a placed camera instead of
-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
@@ -41,7 +41,9 @@ 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 PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local BattleScene = {}
@@ -215,7 +217,11 @@ BattleScene.monCards = monCards
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
tostring(terrain), tostring(token or 0) }
tostring(terrain), tostring(token or 0),
-- the cycle keeps running through a fight, and an arena lit
-- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 128) }
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
@@ -231,17 +237,21 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
end
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil)
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
-- keep contact with their bases instead of starting a bias-width away
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
-- the mons themselves, as the same cards the camera will see. Their alpha
-- is the silhouette, so what lands on the ground is the shape of the
-- Pokemon rather than a blob standing in for one.
for _, card in ipairs(cards or {}) do
ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model)
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
ShadowMap.snug(card.model))
end
ShadowMap.finish(sig)
@@ -298,6 +308,22 @@ function BattleScene.render(state, arena, textures, token)
local host = arena.map or state.map
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
-- the hour's light reaches the arena exactly as it reaches free-roam: the
-- shared rig follows the clock on an outdoor floor and stays at noon on an
-- indoor one, and the same tint multiplies the staged shot -- with the
-- same window glass on whatever buildings stand in the background
local outdoor = host.def and Map.isOutdoor(host.def) or false
DayNight.applyRig(outdoor)
-- a canopy floor (Viridian Forest) fights under the hour's tint too,
-- with the rig and the void exactly as they were
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host))
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
-- no glint in the arena: the drift is the shot breathing, not the player
-- moving, and a shimmer on background windows would fight the mons
Voxel3D.glassGlint = 0
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh = prefetchArena(state, host)
@@ -342,9 +368,12 @@ function BattleScene.render(state, arena, textures, token)
Voxel3D.camera = cam
-- the sun is turned up for the arena and put back afterwards, so the
-- free-roam world it shares this module with keeps its own weight
-- free-roam world it shares this module with keeps its own weight -- and
-- the hour still has the last word: a sunset fades the arena's shadows
-- out and the moon presses more softly, exactly as it does outside
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
@@ -378,10 +407,22 @@ function BattleScene.render(state, arena, textures, token)
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
-- and no voxel wireframe on the pair. Everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that; a
-- mon's card is one quad wearing the battle screen (see
-- BattleBillboard), so it is off the grid and has no seams to draw.
Voxel3D.seams(false)
-- and no glass either: the cards wear the battle screen, not the
-- tileset atlas, so the mask's coordinates mean nothing on them
Voxel3D.glass(false)
for _, card in ipairs(monCards(arena, groundY, textures)) do
-- the sun stored this card snugged (castShadows), so its own shadow
-- lookup must read the same snugged transform -- see ShadowMap.snug
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
BattleBillboard.PULL, ShadowMap.snug(card.model))
end
Voxel3D.glass(true)
Voxel3D.seams(true)
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
@@ -394,10 +435,12 @@ function BattleScene.render(state, arena, textures, token)
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull)
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
local canvas = Voxel3D.endScene()
if not canvas then return end
@@ -430,6 +473,11 @@ function BattleScene.render(state, arena, textures, token)
-- 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,
-- 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
-- indoors, which is what DayNight.tint answers for a room.
tint = Voxel3D.tint,
}
end)
-- the placed camera is ours for exactly this pass; anything else that
+55 -1
View File
@@ -427,6 +427,18 @@ local function runGeometry(map, bodyOnly, masks, sink)
s = nil
end
-- Under the TREES fill the border wall is MODELLED or it is not there
-- (see Structures' hullRingOnly): a ring cell nothing claimed would
-- be a flat-topped box standing beside carved trunks, which reads as
-- a painted-on plateau rather than forest. Structures already stops
-- the ring at the carve distance; this catches the odd cell inside it
-- that the 2x2 grouping could not take -- a canopy whose partners
-- fall outside the shortened ring is left unclaimed, and one strip of
-- boxes along an edge is the whole artefact this avoids.
if not inBody and S.hideBareRing and not S.skip[k] then
s = nil
end
if s and S.skip[k] then
-- an object stands here; paint its synthesized ground and let the
-- prebuilt prism quads (appended below) carry the art
@@ -801,6 +813,33 @@ local function buildFlowerMesh(map)
return quadsMesh(Structures.forMap(map).flowerQuads)
end
-- Authored FIGURES (a person drawn into furniture) as one mesh each, in
-- the card's own local space -- because each one is placed by its own
-- matrix at draw time, leaned back by the camera pitch exactly like a
-- character card (VoxelScene). A figure baked into the terrain mesh could
-- not lean, and a shared mesh could not carry per-figure placement.
--
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
-- none, so the loop that draws them is shorter than the terrain's.
local function buildFigureMeshes(map)
local out = {}
for _, f in ipairs(Structures.forMap(map).figures or {}) do
local mesh = quadsMesh(f.quads)
if mesh then
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
end
end
return out
end
-- Figure lists hold their meshes one level down, so the generic slot
-- release cannot reach them.
local function releaseFigures(list)
for _, f in ipairs(type(list) == "table" and list or {}) do
if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end
end
end
-- Replace a cached slot, releasing whatever mesh it held.
local function swapSlot(c, slot, mesh)
local old = c[slot]
@@ -825,6 +864,8 @@ local function releaseEntry(c)
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
releaseFigures(c.figures)
c.figures = nil
c.stale = nil
end
@@ -863,18 +904,23 @@ end
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
if c.grass == nil or c.flowers == nil or c.figures == nil
or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
local okF, flowers = pcall(buildFlowerMesh, map)
local okX, figures = pcall(buildFigureMeshes, map)
if (gen[job.id] or 0) ~= job.gen then
if okG and grass and grass.release then pcall(grass.release, grass) end
if okF and flowers and flowers.release then
pcall(flowers.release, flowers)
end
if okX then releaseFigures(figures) end
return
end
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
releaseFigures(c.figures)
c.figures = (okX and figures) or false
if c.stale then c.stale.aux = nil end
end
local sink = newSink()
@@ -1022,6 +1068,14 @@ function ChunkMesher.flowers(map)
return c and c.flowers or nil
end
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
-- own leaning matrix at draw time, so they cannot share one mesh.
function ChunkMesher.figures(map)
local c = cache[map.id]
local list = c and c.figures
return (type(list) == "table") and list or nil
end
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
-- replacements cook, and each slot swaps as its build lands. This is
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
+499
View File
@@ -0,0 +1,499 @@
-- Voxel world mode: the day/night cycle -- one clock, and everything the
-- frame asks it.
--
-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
-- the player left is where the cycle picks up. Everything below is a pure
-- function of the clock, so the pinned settings and the running cycle can
-- never drift apart: DUSK is simply the cycle stopped at sunset.
--
-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
-- hanging in the southeast about 45 degrees up. That is the DAY setting and
-- the default, so a player who never touches the row sees the mod they
-- already had. From there the arc swings NORTH at both ends -- rising 70
-- degrees north of east, setting the mirror of that -- because the camera
-- looks north and the northern sky is the only sky it ever frames: a sun
-- that rose due east would light the world for ten minutes without once
-- being seen. Swung north, the disc stands in frame through dawn and dusk
-- (the hours worth looking at) and passes overhead-behind-the-camera
-- through midday, which is where a noon sun belongs.
--
-- THE MOON arcs entirely through the northern sky -- rising northeast, due
-- north at mid-night, setting northwest -- so it hangs over the diorama all
-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
-- softly south, away from it, at about two-thirds the sun's weight.
--
-- SHADOWS are the shear the light throws: direction opposite the body's
-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
-- a long shadow, not an infinite one), strength fading to nothing over the
-- last twelve degrees before the horizon so the handoff between sun and
-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
-- deliberately stays the noon bake: it is a subtle angle term baked into
-- every mesh, and rebaking the world's geometry per phase buys less than
-- the cast shadows, the sky and the tint already say.
--
-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
-- a cave at midnight is exactly as dark as a cave at noon, which is what a
-- room with no windows looks like. Map.isOutdoor is the same test the sky
-- already rests on; the caller passes its answer in (applyRig/tint).
--
-- Persistence: the running cycle's clock is written into the mod's own
-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
-- engine's save.writing event, and read back on save.loaded/created. A save
-- with no clock in it starts at noon.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local PaletteFX = require("src.render.PaletteFX")
local DayNight = {}
-- ------- the dial
DayNight.CYCLE = 1200 -- seconds around the whole dial
DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest
DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight
-- where the pinned settings stop the clock
DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
DayNight.KEY = "daytime"
DayNight.LABEL = "DAYTIME"
-- "sync" first: an unset or unreadable value follows the machine's own
-- clock, per the row's contract (ModSetting values[1] is the default) --
-- and forceSync below reaches for it by the same position.
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
{ "sync", "day", "night", "dusk",
"dawn", "cycle" },
{ "SYNC", "DAY", "NIGHT", "DUSK",
"DAWN", "CYCLE" })
-- The one writer for the FULL pin. While VOXEL sits on FULL the DAYTIME
-- row is off the menu with the rest of the rows the preset owns, and the
-- value is held HERE at SYNC -- the diorama preset's sky follows the clock
-- on the wall, whatever was chosen before. Called from every path that can
-- arrive at or act under FULL (main.lua: the preset itself, the rows hook,
-- the manager's options_changed), mirroring OverworldBattle.forceOG.
function DayNight.forceSync(game)
if DayNight.setting:get() ~= "sync" then
DayNight.setting:setIndex(1, game)
end
end
DayNight.clock = DayNight.T.day -- the running cycle's own position
-- ------- the two arcs
--
-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
-- south), elevations in degrees up from the ground plane.
-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
local NOON_KX, NOON_KZ = -0.85, -0.55
local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
+ NOON_KZ * NOON_KZ)))
local TH_RISE, TH_SET = -70, 250 -- north of east / north of west
local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
local EL_MOON = 40
DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height
DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight
DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press
DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set
-- disc PLACEMENT only: the true elevation would put the noon sun far above
-- any frame, so the arc the discs ride is squashed toward the horizon. The
-- shadows always use the true elevation.
DayNight.ELEV_SQUASH = 0.14
-- three-point arc: a at s=0, b at s=0.5, c at s=1
local function arc(a, b, c, s)
if s < 0.5 then return a + (b - a) * 2 * s end
return b + (c - b) * (2 * s - 1)
end
-- The body lighting the world at clock `t`: bearing and elevation in
-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
-- not the moon rising.
function DayNight.bodyAt(t)
t = t % DayNight.CYCLE
if t <= DayNight.DAY_LEN then
local s = t / DayNight.DAY_LEN
return arc(TH_RISE, TH_NOON, TH_SET, s),
EL_NOON * math.sin(math.pi * s), false
end
local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
return arc(TH_MRISE, TH_MMID, TH_MSET, s),
EL_MOON * math.sin(math.pi * s), true
end
-- The shadow shear that body throws: drift per pixel of height, opposite
-- the bearing, cot(elevation) long, clamped.
function DayNight.shearAt(t)
local th, el, moon = DayNight.bodyAt(t)
if el < 0.5 then el = 0.5 end
local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
end
-- How much shadow the light can press right now, 0..1 of the body's own
-- weight: full up high, gone at the horizon, so sunset hands off to
-- moonrise through a soft shadowless gap instead of snapping.
function DayNight.strengthAt(t)
local _, el = DayNight.bodyAt(t)
local s = el / DayNight.FADE_DEG
if s < 0 then return 0 end
return s < 1 and s or 1
end
-- ------- the palettes
--
-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
-- reads. Six bands, not four: twilight is the whole show here, and six rungs
-- of it is what keeps a sunset reading as a gradient rather than as stripes.
-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
-- after blending, which re-quantises onto it.
-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
-- through. Day's blue horizon and dusk's gold one are near-complements, and
-- a straight lerp between complements bottoms out in grey: mid-transition
-- the whole sky went the colour of dishwater, and gold-to-navy did the same
-- on the far side of sunset. So the evening bends through a golden hour
-- (horizon warming, zenith still blue -- late afternoon), and both edges of
-- the night bend through a violet civil twilight (rose horizon under a
-- violet sky -- the real colour of that half hour).
DayNight.PALETTES = {
day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
{ 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
{ 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
{ 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
{ 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
{ 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
{ 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
}
-- what the world's own colours are multiplied by, per phase (0..255)
DayNight.TINTS = {
day = { 255, 255, 255 },
golden = { 255, 232, 208 },
dawn = { 255, 216, 192 },
dusk = { 255, 192, 168 },
violet = { 184, 160, 200 },
night = { 120, 136, 192 },
}
-- the twilight glow around the low sun, and the discs' own four-shade
-- palettes (lightest first, so a display mode transforms them like any
-- other palette)
DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
{ 248, 144, 80 }, { 216, 96, 64 } }
DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
{ 168, 184, 208 }, { 120, 136, 168 } }
-- The dial as keyframes: a repeated name is a plateau, a change is a
-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
-- waypoint rather than straight across the grey between blue and gold. The
-- morning side needs no waypoint of its own: dawn's pinks into day's blues
-- share a family and blend clean.
local DIAL
local function dial()
if DIAL then return DIAL end
local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
DIAL = {
{ 0, "dawn" }, { B, "day" },
{ D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
{ D + B / 2, "violet" }, { D + B, "night" },
{ C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
}
return DIAL
end
-- Phase weights at clock `t`, off the dial above.
function DayNight.mix(t)
t = t % DayNight.CYCLE
local d = dial()
for i = 1, #d - 1 do
local a, b = d[i], d[i + 1]
if t >= a[1] and t < b[1] then
if a[2] == b[2] then return { [a[2]] = 1 } end
local u = (t - a[1]) / (b[1] - a[1])
return { [a[2]] = 1 - u, [b[2]] = u }
end
end
return { dawn = 1 }
end
-- back onto the 5-bit lattice after any blend
local function q8(v)
v = math.floor(v / 8 + 0.5) * 8
if v < 0 then return 0 end
return v > 248 and 248 or v
end
local function blend3(key, mix, fallback)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = key[name] or fallback
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
return { q8(r), q8(g), q8(b) }
end
-- The sky palette for clock `t`, blended between the phase palettes and
-- re-quantised to the lattice. Memoised per whole second: the answer only
-- moves as the cycle runs, and the cycle moves it slowly.
local palCache = { key = nil, pal = nil }
function DayNight.palette(t)
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if palCache.key == key then return palCache.pal end
local mix = DayNight.mix(t)
local pal = {}
for i = 1, #DayNight.PALETTES.day do
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.PALETTES[name][i]
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
pal[i] = { q8(r), q8(g), q8(b) }
end
palCache.key, palCache.pal = key, pal
return pal
end
-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
-- caller answers for where it is standing (see the header).
local tintCache = { key = nil, tint = nil }
local NEUTRAL = { 1, 1, 1 }
function DayNight.tint(outdoor, t)
if not outdoor then return NEUTRAL end
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if tintCache.key ~= key then
-- NOT re-quantised: this is a light level the shader multiplies by, not
-- a palette colour, and the lattice's 248 ceiling would make even noon
-- fractionally dim
local mix = DayNight.mix(t)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.TINTS[name] or DayNight.TINTS.day
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
tintCache.key = key
tintCache.tint = { r / 255, g / 255, b / 255 }
end
return tintCache.tint
end
-- The twilight glow: how strongly (0..1) and in what colour the sky warms
-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
function DayNight.glow(t)
t = t or DayNight.time()
local _, _, moon = DayNight.bodyAt(t)
if moon then return 0, nil end
local mix = DayNight.mix(t)
local amt = (mix.dawn or 0) + (mix.dusk or 0)
if amt <= 0 then return 0, nil end
return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
end
-- ------- the clock itself
local lastMode = nil
local function mode()
return DayNight.setting:get() or "day"
end
-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
-- fraction. A named seam rather than a bare os.date call, so the suite can
-- hand it a fixed hour.
function DayNight.hours()
local d = os.date("*t")
return d.hour + d.min / 60 + d.sec / 3600
end
-- SYNC: the machine's own time of day laid onto the dial. Local noon is
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
-- falls when the player's does.
function DayNight.syncTime()
return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
end
-- The effective time: the pin, the running clock under CYCLE, or the wall
-- clock under SYNC.
function DayNight.time()
local m = mode()
if m == "cycle" then return DayNight.clock end
if m == "sync" then return DayNight.syncTime() end
return DayNight.T[m] or DayNight.T.day
end
-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
-- (which ticks through battles and menus too, so night falls during a long
-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
-- the pin the player was just looking at: DUSK then CYCLE rolls on into
-- night rather than teleporting the sky.
function DayNight.update(dt)
local m = mode()
if m ~= lastMode then
if m == "cycle" then
-- from a pin, its time; from SYNC, wherever the real sky already was
DayNight.clock = DayNight.T[lastMode]
or (lastMode == "sync" and DayNight.syncTime())
or DayNight.clock
end
lastMode = m
end
if m == "cycle" and dt and dt > 0 then
DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
end
end
-- The clock the RIG runs on: quantised, so the shadow map redraws a few
-- times a minute as the sun crawls rather than every frame.
DayNight.STEP = 2
function DayNight.rigTime()
local t = DayNight.time()
return math.floor(t / DayNight.STEP) * DayNight.STEP
end
-- ------- what the frame reads
-- Point the shared light rig at the clock -- or at noon, indoors. This
-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
-- sunDark uniform), so no draw path changes to follow the sun; they follow
-- the rig, and the rig follows the clock.
function DayNight.applyRig(outdoor)
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local t = outdoor and DayNight.rigTime() or DayNight.T.day
local kx, kz, moon = DayNight.shearAt(t)
ShadowMap.KX, ShadowMap.KZ = kx, kz
Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
return t
end
-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
-- caller that sets its own alpha (the battle arena) and should still lose
-- its shadows to a sunset.
function DayNight.shadowScale(outdoor, t)
if not outdoor then return 1 end
t = t or DayNight.rigTime()
local _, _, moon = DayNight.bodyAt(t)
local s = DayNight.strengthAt(t)
return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
end
-- The disc to hang in the sky, or nil when the body is set or behind the
-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
-- is; the caller projects it through its own camera.
function DayNight.body(t)
t = t or DayNight.time()
local th, el, moon = DayNight.bodyAt(t)
if el < -2 then return nil end
local e = math.rad(el * DayNight.ELEV_SQUASH)
local b = math.rad(th)
return {
dx = math.cos(b) * math.cos(e),
dy = math.sin(e),
dz = math.sin(b) * math.cos(e),
moon = moon,
}
end
-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun
-- or moon to see, so the shadow rig stays the mod's fixed noon light,
-- which is all that ever filtered through the leaves -- but not a sealed
-- room either: night still FALLS in them. Of everything the clock does,
-- exactly one thing reaches a canopy map: the hour's tint.
DayNight.CANOPY = { VIRIDIAN_FOREST = true }
function DayNight.isCanopy(map)
return (map and map.id and DayNight.CANOPY[map.id]) and true or false
end
-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky.
-- They come on through dusk (a lit window against a sunset is half the point
-- of having either), burn all night, and are mostly out again by dawn:
-- people wake before it is bright, they do not read at sunrise.
local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 }
function DayNight.windowLight(t)
local mix = DayNight.mix(t or DayNight.time())
local lit = 0
for name, w in pairs(mix) do
lit = lit + (LAMPS[name] or 0) * w
end
return lit
end
-- The period name for the engine's world.tod hook (map.palette ctx.tod,
-- music.select): the dominant phase, in the vocabulary day/night mods use.
local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
function DayNight.tod(t)
local mix = DayNight.mix(t or DayNight.time())
local best, bestW = "day", -1
for name, w in pairs(mix) do
if w > bestW then best, bestW = name, w end
end
return TOD[best] or "DAY"
end
-- ------- persistence
--
-- The clock rides the SAVE SLOT, not the options file: what time it is in
-- Kanto is a fact about that journey, like where the player is standing.
-- mod.save is the loader's per-mod bucket in save.modData, which persists
-- with the slot on its own -- writing the value is all there is to do.
DayNight.SAVE_KEY = "clock"
function DayNight.store()
local saveApi = V.mod and V.mod.save
if not (saveApi and saveApi.set) then return end
pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
end
function DayNight.restore()
local saveApi = V.mod and V.mod.save
local stored = nil
if saveApi and saveApi.get then
local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
if ok then stored = got end
end
-- no time set: it is day (the requirement, verbatim)
DayNight.clock = type(stored) == "number"
and stored % DayNight.CYCLE or DayNight.T.day
end
return DayNight
+169
View File
@@ -0,0 +1,169 @@
-- The hour's light on the FLAT world.
--
-- The clock already reaches everything the 3D pass draws: VoxelScene and
-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
-- mode off and none of that happens -- the tint is a uniform in a shader the
-- flat tile path never runs -- so the same evening that fell on the diorama
-- left the 2D world at permanent noon. One clock, two worlds, one of them
-- ignoring it.
--
-- So the flat composite gets the same multiply, painted as one rectangle.
--
-- ------- WHERE, which is the only difficult part
--
-- Not on the world canvas. In a colorized mode that canvas is grayscale art
-- and the blit that puts it on screen runs it through the palette shader,
-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
-- night blue over it first and every shade lands in the wrong bucket -- the
-- world would not darken, it would change colour into whatever the palette
-- said the wrong bucket was.
--
-- So it goes on AFTER that pass, on the composited world. And not after the
-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
-- the HUD are paper held up in front of the world rather than part of it --
-- the same reason the tilt-shift blur is a worldPresent and not a present.
--
-- Which leaves one instant: between the world blit and the UI blit, inside
-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
-- hook for exactly this, only runs when a PIPELINE produced the world, which
-- in flat mode is the one thing that did not happen. So endFrame is wrapped
-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
-- by identity rather than by counting or guessing.
--
-- The shader and scissor that call arrives under belong to the UI blit already
-- in progress, so both are put aside for the rectangle and handed straight
-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
--
-- ------- WHEN
--
-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
-- load-bearing:
--
-- the flat path a pipeline that rendered the world already applied the
-- tint inside its own shader; painting it again would apply
-- the hour twice. worldOverride is exactly "a pipeline drew
-- this frame".
-- outdoors a room has no sky to take its light from, which is the
-- same answer DayNight.tint gives on its own and the same
-- one applyRig gives the sun.
-- not neutral midday is a multiply by white. Skipped rather than drawn,
-- so a game with the clock at DAY issues not one extra call.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local DayNight = V.require("DayNight")
local DayTint = {}
-- Below this the tint is close enough to white that the rectangle would not
-- change a pixel, and the frame is left exactly as it was.
DayTint.NEUTRAL = 0.999
local function outdoorNow()
local ok, Game = pcall(require, "src.core.Game")
if not ok then return false end
local ow = Game and Game.overworld
local map = ow and ow.map
if not map then return false end
local okMap, Map = pcall(require, "src.world.Map")
if not okMap then return false end
local outdoor = map.def and Map.isOutdoor(map.def) or false
-- a canopy floor takes the hour's colour and nothing else of it, exactly as
-- it does in the 3D pass (BattleScene, VoxelScene)
return outdoor or DayNight.isCanopy(map)
end
-- The colour this frame's world should be multiplied by, or nil to leave the
-- frame alone.
function DayTint.forFrame(renderer)
if not renderer then return nil end
if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
if not renderer.worldActive then return nil end -- no world on screen at all
if not outdoorNow() then return nil end
local tint = DayNight.tint(true)
if not tint then return nil end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
return nil
end
return r, g, b
end
-- One rectangle over the window, multiplied into whatever is under it.
--
-- The whole window rather than the world's own rect, which is what the
-- engine's warp fade does from the same place and for the same reason: the
-- border fill, the letterbox bars and the world are all "the world" as far as
-- the hour is concerned, and black multiplied by anything is still black.
-- Every read of the graphics state is optional, because a headless driver
-- ships some of these and not others -- the same reason TerrainAtlas reads the
-- engine's seams guarded. What cannot be read cannot be put back either, and a
-- missing accessor must cost the tint rather than the frame.
local function saved(name, ...)
local fn = love.graphics[name]
if not fn then return nil end
local ok, a, b, c, d = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d
end
function DayTint.paint(r, g, b)
local gfx = love.graphics
local shader = saved("getShader")
local sx, sy, sw, sh = saved("getScissor")
local blend, alpha = saved("getBlendMode")
local pr, pg, pb, pa = saved("getColor")
local w, h = gfx.getDimensions()
if gfx.setShader then gfx.setShader() end
if gfx.setScissor then gfx.setScissor() end
gfx.setBlendMode("multiply", "premultiplied")
gfx.setColor(r, g, b, 1)
gfx.rectangle("fill", 0, 0, w, h)
gfx.setBlendMode(blend or "alpha", alpha)
gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
if gfx.setScissor then
if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
end
if shader and gfx.setShader then gfx.setShader(shader) end
end
function DayTint.install()
local Renderer = require("src.render.Renderer")
if Renderer.dramaticShapeTintHook then return end
local inner = Renderer.endFrame
function Renderer:endFrame(zones, worldZones)
local r, g, b = DayTint.forFrame(self)
if not r then return inner(self, zones, worldZones) end
local gfx = love.graphics
local draw = gfx.draw
local ui = self.canvas
local painted = false
gfx.draw = function(tex, ...)
-- the UI canvas reaching the screen: the world is finished, the paper
-- in front of it has not started. Restored FIRST so the rectangle's own
-- drawing cannot re-enter this, and so a UI blit that draws one quad per
-- SGB zone only triggers it once.
if not painted and tex == ui then
painted = true
gfx.draw = draw
DayTint.paint(r, g, b)
end
return draw(tex, ...)
end
local ok, err = pcall(inner, self, zones, worldZones)
gfx.draw = draw
if not ok then error(err, 0) end
end
Renderer.dramaticShapeTintHook = true
end
return DayTint
+174
View File
@@ -0,0 +1,174 @@
-- Voxel world mode: the glass in the windows, found rather than listed.
--
-- Buildings and doors in the overworld art carry small panes -- six texels
-- wide, framed in black, with a diagonal shine drawn in. This module finds
-- them by SHAPE in the tileset image itself: a border row of six black
-- texels, four or five rows of black-flanked non-black glass under it, and
-- a closing border row. No tile ids are hardcoded, so a total conversion
-- that draws its own windows in the same idiom gets glass for free, and art
-- with no windows gets an empty mask and costs nothing.
--
-- The scan slides at PIXEL granularity because the art does: the building
-- window sits a row down inside its tile, and the door's pane straddles a
-- 2x2 tile block entirely -- a per-tile matcher finds neither.
--
-- What the scan yields is a MASK TEXTURE the same size as the tileset
-- atlas: opaque white on glass texels, transparent everywhere else. Terrain
-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect),
-- so the scene shader can sample this mask with the SAME coordinates and
-- know, per fragment, whether it is drawing glass -- on any wall, at any
-- angle, in free-roam or a staged battle, with no geometry work anywhere.
-- The recoloured atlases (display modes, RED++) keep the tileset's layout,
-- so the alignment holds under every palette.
--
-- What the shader does with the answer (Voxel3D): by day a thin glint
-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving
-- the art under it -- and after dark the panes are LIT: the texel's own
-- shine pattern, warmed and brightened, exempt from the sun, the shadow
-- map and the hour's tint, as a window with a lamp behind it is.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local GlassMask = {}
-- pane geometry the scan accepts: six glass texels across, and this many
-- rows of them between the two border rows
GlassMask.GLASS_W = 6
GlassMask.ROWS = { 4, 5 } -- door pane, building pane
-- Whether a channel triple is the border black. The raw tileset art is the
-- four DMG greys, so black is genuinely zero; the threshold forgives a
-- rescaled asset without accepting the dark grey rung (85/255 = 0.33).
local function isBlack(r, g, b)
return r < 0.12 and g < 0.12 and b < 0.12
end
GlassMask._isBlack = isBlack -- named for the suite
-- Find every pane in an image, through a pure reader so the geometry is
-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based
-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels
-- (the border is the detector's evidence, not part of the answer).
function GlassMask.scan(getPixel, w, h)
local function black(x, y)
return isBlack(getPixel(x, y))
end
local function borderRow(x, y)
for c = 1, 6 do
if not black(x + c, y) then return false end
end
return true
end
local function glassRow(x, y)
if not (black(x, y) and black(x + 7, y)) then return false end
for c = 1, 6 do
if black(x + c, y) then return false end
end
return true
end
local want = {}
for _, n in ipairs(GlassMask.ROWS) do want[n] = true end
local rects = {}
for y = 0, h - 1 do
for x = 0, w - 8 do
if borderRow(x, y) then
local n = 0
while y + 1 + n < h and glassRow(x, y + 1 + n) do
n = n + 1
end
if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then
rects[#rects + 1] = { x = x + 1, y = y + 1,
w = GlassMask.GLASS_W, h = n }
end
end
end
end
return rects
end
-- ------- the runtime cache, one entry per tileset image
local cache = {} -- image path -> { rects, texture (or false) }
local function entry(tileset)
local path = tileset and tileset.image
if not path then return nil end
local hit = cache[path]
if hit then return hit end
local ok, data = pcall(Assets.imageData, path)
if not (ok and data) then
-- unreadable art is a verdict for the session, not a retry loop
cache[path] = { rects = {}, texture = false }
return cache[path]
end
local w, h = data:getDimensions()
local rects = GlassMask.scan(function(x, y)
return data:getPixel(x, y)
end, w, h)
local texture = false
if #rects > 0 and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage then
local built = pcall(function()
local mask = love.image.newImageData(w, h)
for _, r in ipairs(rects) do
for yy = r.y, r.y + r.h - 1 do
for xx = r.x, r.x + r.w - 1 do
mask:setPixel(xx, yy, 1, 1, 1, 1)
end
end
end
texture = love.graphics.newImage(mask)
texture:setFilter("nearest", "nearest")
end)
if not built then texture = false end
end
cache[path] = { rects = rects, texture = texture }
return cache[path]
end
-- The panes found in a tileset's art, as glass rects in atlas pixels.
function GlassMask.rects(tileset)
local e = entry(tileset)
return e and e.rects or {}
end
-- The mask texture for a tileset, or nil when it has no panes (or the art
-- is unreadable, or there is no GPU) -- callers bind the blank instead.
function GlassMask.texture(tileset)
local e = entry(tileset)
return (e and e.texture) or nil
end
-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask:
-- the scene shader always declares the sampler, and an unbound sampler is
-- a driver-dependent crash rather than a fallback.
local blank = nil
function GlassMask.blank()
if blank == nil then
local ok, img = pcall(function()
local data = love.image.newImageData(1, 1)
data:setPixel(0, 0, 0, 0, 0, 0)
return love.graphics.newImage(data)
end)
blank = (ok and img) or false
end
return blank or nil
end
-- Drop the GPU objects (window resize, hot reload). The rects survive --
-- they are a fact about the art -- but textures are rebuilt on demand.
function GlassMask.invalidate()
for _, e in pairs(cache) do
if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end
e.texture = false
end
cache = {}
blank = nil
end
return GlassMask
+65
View File
@@ -0,0 +1,65 @@
-- Voxel world mode: decoded pixels, kept.
--
-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads
-- resolve the same way but stay uncached: the caller keeps the derived
-- product" (src/render/Assets.lua) -- which is the right contract for the
-- flat renderer, whose one caller decodes a strip once and keeps the strip.
--
-- This mod is not that caller. It reads the same handful of images over and
-- over, from several places that do not know about each other:
--
-- * the tileset atlas, decoded by Structures (its own cache), by
-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by
-- TerrainAtlas again to learn a tile's shades, and by GlassMask;
-- * the FLOWER FRAME files, decoded inside patch() -- which runs every
-- time the animation step turns over, about three times a second, for
-- as long as the map is on screen. That one is not a load cost at all,
-- it is a recurring per-second cost on the render thread, and it was
-- the single clearest waste the first profile turned up.
--
-- So: one table, keyed by the path as the CALLER gave it, holding the
-- decoded ImageData. Registered with Assets.invalidate so a hot reload
-- drops it alongside every other downstream cache.
--
-- The entries are never evicted by size. That is deliberate and bounded:
-- what lands here is tileset art and animation frames -- a few dozen small
-- images for a whole session, tens of kilobytes each -- not per-map bakes,
-- which have their own eviction in TerrainAtlas.setLive.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Perf = V.require("Perf")
local ImageCache = {}
local cache = {}
-- The decoded pixels for `path`, or nil when it cannot be read.
--
-- `false` is cached for an unreadable path, so a missing or corrupt asset
-- costs one failed decode for the session rather than one per frame -- the
-- same sticky-failure shape the rest of this mod uses for GPU objects.
function ImageCache.get(path)
if not path then return nil end
local hit = cache[path]
if hit ~= nil then
Perf.count("imageCache.hit")
return hit or nil
end
local t0 = Perf.now()
local ok, data = pcall(Assets.imageData, path)
Perf.add("ImageCache.decode", t0)
Perf.count("imageCache.miss")
cache[path] = (ok and data) or false
return cache[path] or nil
end
function ImageCache.invalidate()
cache = {}
end
Assets.register(ImageCache.invalidate)
return ImageCache
+431 -12
View File
@@ -72,6 +72,38 @@ function OverworldBattle.enabled()
return OverworldBattle.setting:get() and true or false
end
-- ------- BACK SPRITES: the player's own mon stays on the menu
--
-- The staged shot stands BOTH mons on the map, which is the mode's whole
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
-- with its feet on the box. That silhouette is the series' shot.
--
-- So BACK SPRITES is offered as a middle setting rather than a compromise
-- imposed on everyone. With it on the foe is still geometry standing on its
-- tile at the far end of the arena, and the player's side goes back to being
-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
-- feet on row 96.
-- Nothing else about the shot moves -- the arena, the camera and the drift are
-- solved exactly as they were, so the foe stands where it always stood and the
-- player's cell is simply empty ground in the foreground.
--
-- OFF by default: what the mode advertises is the pair of them out there.
OverworldBattle.BACK_KEY = "battleBack"
OverworldBattle.BACK_LABEL = "BACK SPRITES"
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
OverworldBattle.BACK_LABEL,
{ false, true }, { "OFF", "ON" })
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
-- battle screen already draws exactly this.
function OverworldBattle.backPinned()
if not OverworldBattle.enabled() then return false end
return OverworldBattle.backSetting:get() and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
@@ -81,6 +113,10 @@ end
-- through the engine's own pokemon.sprite hook -- the seam that exists for
-- exactly this, so no battle code has to be touched to get it.
--
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
-- that mon is drawn in its own slot on the menu, seen from behind, and the
-- front art would be it turned round to face the player it belongs to.
--
-- Answered BEFORE a battle exists, because the battler is built before the
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
-- whether one on this map WOULD be -- the row is on, the 3D pass is
@@ -91,6 +127,7 @@ local staged = { mapId = nil, ok = false }
function OverworldBattle.wantsFront()
if not OverworldBattle.enabled() then return false end
if OverworldBattle.backPinned() then return false end
if not Voxel3D.available() then return false end
-- required here rather than through the file's own helper: this runs
-- while a battler is being built, which is before that helper is defined
@@ -140,6 +177,110 @@ OverworldBattle.HUD_RECT = {
player = { 72, 56, 88, 40 },
}
-- ------- the box at the bottom, on the same glass
--
-- The HUDs got frosted panels because black glyphs on grass are not readable.
-- The battle's text box and its menu had the opposite problem and the same
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
-- the field's own colour when the field was white and is a sheet of paper laid
-- over the bottom third of the diorama now that it is not.
--
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
-- frosted glass and laid back down translucent, with the border and the text
-- drawn over it unchanged, and the same brightness verdict flipping the ink
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
-- the engine draws inside the box is still the engine's own, in its own place.
--
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
-- READ-ONLY duplicates of that function's own branches, the same kind of
-- mirror hudLive is and for the same reason: there is no seam that reports "a
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
-- The worst a future engine change can do is frost a rectangle nothing lands
-- on, or leave a box unfrosted -- never break a battle.
--
-- Each rect stops where the next one starts rather than overlapping it: two
-- panels over the same pixels would frost it twice and leave a visible step
-- along the seam.
OverworldBattle.TEXT_RECT = {
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
-- above the box above -- its last tile row sits inside that one
moves = { 0, 64, 88, 32 },
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
mimic = { 0, 56, 128, 40 },
}
function OverworldBattle.textRects(battle)
if not battle or battle.blankForAskName then return {} end
local r = OverworldBattle.TEXT_RECT
local out = { box = r.box }
if battle.phase == "moveSelect" then
out.moves = r.moves
elseif battle.phase == "mimicSelect" then
out.mimic = r.mimic
end
return out
end
-- ------- the HUDs, out at the window's own edges
--
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
-- whole of it. That left both HUD blocks huddled together in the middle of the
-- frame with map showing on either side of them, which reads as a Game Boy
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
--
-- So each block is snapped to its own side: the foe's to the left edge of the
-- window, the player's to the right. Nothing about either block changes -- same
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
-- of the GB screen there is nowhere to go and the snap is a no-op.
--
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
-- canvas and everything outside it is clipped away. So the layer is rendered to
-- a texture and composited into the WORLD image instead, which is the one
-- surface in this mode that covers the whole window.
-- The rows each block is cut out of, full width. Generous on purpose:
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
-- further than the panel does, and the pokeball rows and the safari ball count
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
-- outside rows 0-96, and the two bands split that between them.
OverworldBattle.HUD_BAND = {
enemy = { 0, 0, 160, 48 },
player = { 0, 48, 160, 48 },
}
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
-- glass is cut to, plus the x its band is blitted at.
--
-- The foe's panel starts at the window's left edge and the player's ends at the
-- right one. The vertical is untouched, so both stay on the rows the GB put
-- them on. A band's own origin sits outside the window by the panel's inset --
-- the couple of pixels a HUD shake can push past the edge are clipped there,
-- which is the whole cost of the snap and is invisible.
function OverworldBattle.snapRects(shot)
local s = shot.scale
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
local ex = -e[1] * s -- foe: panel's left edge to 0
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
local rects = {
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
}
return rects, { enemy = ex, player = px }
end
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
-- where it always was -- but its glass is laid into the world image alongside
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
local function toWorld(rect, shot)
local s = shot.scale
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
rect[3] * s, rect[4] * s }
end
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
@@ -150,6 +291,13 @@ local function game()
return require("src.core.Game")
end
-- Whether this frame's HUDs went out to the window's edges instead of being
-- drawn in the GB frame. False whenever the composite could not be made, which
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
local function snapped()
return (session and session.snapped) and true or false
end
-- Put the map's cast back. Both lists are handed back by identity, so
-- anything that captured one before the battle still sees the same table.
local function restoreCast()
@@ -174,6 +322,36 @@ local function cullCast(state)
state.ghosts = {}
end
-- ------- one right battle layout
--
-- Everything this file composes is measured in the GB's own 160x144 frame: the
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
-- the frosted panels are cut to, and the full-frame white intercepted to let
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
-- -- the mons would stand where no camera was solved for them, and the panels
-- would land beside the HUDs they are supposed to be under.
--
-- So while a fight can be staged on the map there is one right answer, and it
-- is SET rather than worked around. The engine reads the option live
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
-- top state for its surface the same way), so writing it here lands on the
-- battle being pushed as well as every one after it.
--
-- This is the last line rather than the first: the OPTIONS menu takes the row
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
-- player is never offered a switch that gets reverted under them. What reaches
-- here is a value that arrived some other way -- a save written before the mod
-- was installed, the mod manager's own page, another mod.
function OverworldBattle.forceOG(g)
g = g or game()
local opts = g and g.save and g.save.options
if not opts or opts.battleLayout ~= "wide" then return false end
opts.battleLayout = "og"
if g.writeOptions then pcall(g.writeOptions, g) end
return true
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
@@ -189,6 +367,10 @@ function OverworldBattle.begin(state, battle)
state.player.surfing)
if not (ok and arena) then return false end
-- the fight is staged from here on, so the layout it is composed for is not
-- optional any more (see forceOG)
OverworldBattle.forceOG()
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
@@ -276,11 +458,13 @@ function OverworldBattle.update(dt)
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
-- guard, which retires a pipeline for the session.
session.shot = nil
session.snapped = false
session.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
session.snapped = false
if shot and shot.canvas then
-- the depth of field is measured off the two marks: the slab in focus is
-- the one the mons are standing in, at whatever the drift has done to
@@ -295,6 +479,20 @@ function OverworldBattle.update(dt)
-- so a panel over a blurred far field is frosted from what is actually
-- behind it
pcall(BattleHud.build, shot.canvas)
-- and then the HUDs go ON that backdrop, snapped out to the window's own
-- edges (snapHUDs). Here rather than in the battle's draw for the same
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
end
end
session.shot = shot
end
@@ -366,6 +564,81 @@ local function withoutBackgroundFill(battle, fn)
if not ok then error(err, 0) end
end
-- ------- the box, without its paper
--
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
-- opaque slab the frosted panel underneath is there to replace. So for the
-- length of one drawTextArea the white fills are dropped and everything else
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
-- always did, over the glass instead of over paper.
--
-- Every fill drawTextArea issues is one of those: the box's own, and the two
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
-- the border glyphs that hardware would have overwritten. Both are opaque
-- white, both are paper, and both go.
--
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
-- installed around a single call, removed on the way out including on error,
-- never live outside a battle frame this mode is drawing.
local function withoutBoxFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
if mode == "fill" then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
end
return rectangle(mode, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the hour's light, on a pic that is not geometry
--
-- Everything standing in the arena goes through the voxel shader, and that
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
-- night it goes blue, and the two mons' cards go with it because they are
-- drawn in the same pass as the ground they stand on.
--
-- A back pic pinned to the menu is not in that pass. It is the engine's own
-- flat blit over the finished shot, so it arrived at noon while the world
-- behind it was at midnight -- a mon lit by nothing in the frame.
--
-- So the tint is applied by hand, to that one draw. Every colour the pics
-- layer sets is multiplied on its way past, which is the whole of it: the
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
-- slide's fade and the blink's own colour all compose with it rather than
-- being overwritten.
--
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
-- standing under a tree is darker than the tint alone, and this pic has no
-- position in the scene to be shadowed at. It carries the hour and not the
-- weather, which is the part the eye reads.
local function withTint(tint, fn, ...)
if not tint then return fn(...) end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
local gfx = love.graphics
local setColor = gfx.setColor
gfx.setColor = function(cr, cg, cb, ca, ...)
if type(cr) == "table" then
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
cr[4] }, cg, ...)
end
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
end
local ok, err = pcall(fn, ...)
gfx.setColor = setColor
-- the layer leaves whatever colour it last set, and that one is tinted;
-- hand the next caller plain white rather than a dimmed one
setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
end
-- ------- the mons, as textures for the 3D pass
--
-- The two Pokemon are not composited over the world any more: they are quads
@@ -394,6 +667,7 @@ local texturing = nil
local texCanvas = {}
local innerPics = nil -- captured by install()
local innerHUDs = nil -- likewise, for the snapped HUD layer
local function texCanvasFor(side)
local c = texCanvas[side]
@@ -494,11 +768,19 @@ function OverworldBattle.flashing(battle)
end
-- Both sides, or nil when neither has anything to show.
--
-- One side under BACK SPRITES: the player's mon is not standing on the map at all
-- there, it is on the menu, so it has no card to be a texture for -- and
-- nothing downstream has to know that. No billboard, and no shadow on the
-- ground under a mon that is not on it.
function OverworldBattle.textures(battle)
if not battle then return nil end
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
local okP, player = true, nil
if not OverworldBattle.backPinned() then
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
end
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
@@ -613,10 +895,44 @@ function OverworldBattle.install()
-- before this screen is composited at all, so the flat pics layer has
-- nothing left to do here. Skipped rather than left to draw underneath, or
-- every Pokemon would appear twice: once on its tile and once in its slot.
--
-- Except under BACK SPRITES, where the player's side never became geometry and this
-- layer is the only thing that draws it. The engine's own onlySide argument
-- does the whole job: one call, the player's branches alone, in the slot and
-- at the scale the GB always put them -- feet on the box, 2x, back view.
innerPics = BattleState.drawPicsLayer
function BattleState:drawPicsLayer(slide, sx, sy)
if self.dramaticShapeShot then return end
return innerPics(self, slide, sx, sy)
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
local shot = self.dramaticShapeShot
if not shot then
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
end
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
-- under the hour's own light, like everything else in the frame -- see
-- withTint, and the tint BattleScene hands over with the shot.
--
-- Except on the wavy path, where the pic is baked into the GRAYSCALE bg
-- canvas for the zone pass to colour by region. That pass keys off the
-- red channel, and a night tint pulls red down -- it would not darken
-- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a
-- second and the hour survives it fine.
local tint = not self.grayPics and shot.tint or nil
return withTint(tint, innerPics, self, slide, sx, sy, "player",
skipMenuClip)
end
end
-- The battle's text box and its menus, over the frosted glass laid down for
-- them rather than over their own white paper -- and their ink flipped with
-- the HUD's when the ground under the frame is dark, by the same rule and
-- off the same verdict.
local innerText = BattleState.drawTextArea
function BattleState:drawTextArea()
if not self.dramaticShapeShot then return innerText(self) end
local battle = self
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
withoutBoxFill(battle, innerText)
end)
end
-- Move animations are authored against the pics' fixed slots, and a single
@@ -634,10 +950,13 @@ function OverworldBattle.install()
-- mons' projected positions, less the midpoint of the slots they used to
-- sit in. A hit still lands on the mon it is aimed at.
local a = OverworldBattle.ANCHOR
local dx = (shot.enemy[1] + shot.player[1]) / 2
- (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + shot.player[2]) / 2
- (a.enemy[2] + a.player[2]) / 2
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
-- contributes no movement at all and the pair's centre has gone half as
-- far as the foe's mark did.
local px, py = shot.player[1], shot.player[2]
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
love.graphics.push()
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
local ok, err = pcall(innerAnim, self, colorized)
@@ -695,8 +1014,12 @@ function OverworldBattle.install()
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
local innerHUDs = BattleState.drawHUDs
innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
-- Normally the HUDs have already been drawn this frame, snapped out to the
-- window's edges and composited into the world image (snapHUDs). Drawing
-- them here as well would show each block twice, once in each place.
if self.dramaticShapeShot and snapped() then return end
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
@@ -726,19 +1049,115 @@ function OverworldBattle.hudLive(battle, slide)
return enemy and true or false, player and true or false
end
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
-- ------- the snapped composite
--
-- The engine's own HUD layer, rendered into a texture.
--
-- One thing is falsified for the render, and it is falsified because this layer
-- never reaches the battle's zone pass -- it is composited into the world image,
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
-- answered false, it tints its own greens and reds instead, exactly as it does
-- on the flat path. The glyphs are pure black either way, which is what the
-- flip in BattleHud.layerTexture is measured against.
--
-- Shadowed on the instance for this call only, the way drawZonePass shadows
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
-- class method be found again.
function OverworldBattle.hudTexture(battle, slide, dark)
if not (innerHUDs and battle) then return nil end
local had = rawget(battle, "colorMode")
battle.colorMode = function() return false end
local ok, layer = pcall(BattleHud.layerTexture,
BattleScene.GB_W, BattleScene.GB_H, dark,
function() innerHUDs(battle, slide) end)
battle.colorMode = had
return ok and layer or nil
end
-- Draw both HUD blocks into the world image at the window's edges, each on its
-- own frosted panel. Returns true when the frame's HUDs are up there and the
-- in-frame draw must be skipped; false leaves the battle screen's own HUD
-- exactly as it was before any of this existed.
--
-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
-- carries more than the HUD: the pokeball rows of the intro and of an enemy
-- faint, and the safari ball count, all draw in these rows and belong at the
-- same edge as the block they share it with. The panels are the ones that
-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
function OverworldBattle.snapHUDs(battle, shot)
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
return false
end
local slide = (battle.introSlide or 0) * 4
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)
end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
-- corner and black ones on the menu reads as a bug rather than as adaptation.
local dark = BattleHud.verdict(live, shot, true)
-- the box's own ink is flipped where the engine draws it, in the GB frame,
-- so the answer has to outlive this function (see drawHudPanels)
if session then session.dark = dark end
local layer = OverworldBattle.hudTexture(battle, slide, dark)
if not layer then return false end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(shot.canvas)
g.setBlendMode("alpha")
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
g.setColor(1, 1, 1, 1)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
BattleScene.GB_W, BattleScene.GB_H)
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return true
end
-- Lay the frosted glass down under whichever HUD and box are about to draw,
-- and record which way the glyphs have to flip.
--
-- The panels are the fallback path only: normally the HUDs are snapped out to
-- the window's edges and their glass, and the box's, went into the world image
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
-- drawn here, in the GB frame, whichever path laid the glass under it.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
if snapped() then
battle.dramaticShapeDark = session and session.dark or nil
return
end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
local rect = OverworldBattle.HUD_RECT
local live = {}
if enemy then live.enemy = rect.enemy end
if player then live.player = rect.player end
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
if not next(live) then return end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
+353
View File
@@ -0,0 +1,353 @@
-- Voxel world mode: the instrumentation core.
--
-- Ships DARK. Every entry point is one boolean test away from doing
-- nothing, and the boolean is false unless a run explicitly asks for
-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the
-- save directory for a device that has no environment to set). A mod that
-- measures itself in every player's session is a mod that costs every
-- player the measurement, so the default has to be off and the off path
-- has to be free.
--
-- What it measures, and why those three things:
--
-- * LABELS -- named spans (a bake, a mesh build, a shader compile),
-- accumulated as {n, total, max}. `max` is the one that matters: a
-- bake that costs 40ms ONCE is a visible hitch, and an average hides
-- it completely.
-- * FRAMES -- a ring of the last N whole-frame times, stamped once per
-- rendered frame. Frame time is the only number the player actually
-- experiences; every label total is a hypothesis about which frames.
-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas
-- rebakes). Cheaper than a span when the question is "how often",
-- not "how long".
--
-- Spans are wall time, and on a GPU that means submission time, not
-- completion time -- the driver is free to finish the work later. So a
-- GPU-side saving shows up in the FRAME numbers rather than in the label
-- for the pass that caused it, and both are reported.
local Perf = {}
local clock = (love and love.timer and love.timer.getTime) or os.clock
-- Read through pcall: the loader's sandbox does not hand a mod `os`, and
-- instrumentation must never be the reason the mod fails to load. Same
-- shape as OverworldBattle's DS_BATTLE_DEBUG probe.
local function envFlag(name)
local ok, value = pcall(function() return os.getenv(name) end)
if not ok then return nil end
if value == nil or value == "" or value == "0" then return nil end
return value
end
local function flagFile()
if not (love and love.filesystem and love.filesystem.getInfo) then
return false
end
local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag")
return ok and info ~= nil
end
Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile()
Perf.labels = {} -- label -> { n, total, max }
Perf.order = {} -- insertion order, so a report reads chronologically
Perf.counters = {} -- name -> integer
Perf.frames = {} -- ring of frame times, seconds
Perf.frameCount = 0
Perf.RING = 4096
-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark
-- names the phase it is driving; every frame and every label span
-- recorded while that name is set is attributed to it, which is what
-- turns "the walk was slow" into "the walk was slow ONLY on the frames
-- right after ROUTE_1 came into view".
Perf.segment = nil
Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} }
local function segmentEntry()
local name = Perf.segment
if not name then return nil end
local s = Perf.segments[name]
if not s then
s = { name = name, frames = {}, labels = {}, order = {} }
Perf.segments[name] = s
Perf.segments[#Perf.segments + 1] = s -- array half preserves order
end
return s
end
function Perf.setSegment(name)
Perf.segment = name
if name then segmentEntry() end
end
-- ---------------------------------------------------------------- spans
--
-- Call shape at the measured site:
--
-- local t0 = Perf.now()
-- ... the work ...
-- Perf.add("TerrainAtlas.staticAtlas", t0)
--
-- When disabled, now() returns nil and add() returns on the nil -- two
-- function calls and a branch, no table touched, no string built. Sites
-- that would run thousands of times a frame (per draw call, per vertex)
-- are still too hot for that and are deliberately NOT instrumented; the
-- frame ring covers them in aggregate.
function Perf.now()
if not Perf.enabled then return nil end
return clock()
end
local function bump(store, order, label, dt)
local s = store[label]
if not s then
s = { n = 0, total = 0, max = 0 }
store[label] = s
order[#order + 1] = label
end
s.n = s.n + 1
s.total = s.total + dt
if dt > s.max then s.max = dt end
end
function Perf.add(label, t0)
if t0 == nil then return end
local dt = clock() - t0
bump(Perf.labels, Perf.order, label, dt)
local seg = segmentEntry()
if seg then bump(seg.labels, seg.order, label, dt) end
end
-- Wrap a function in a table, in place. Used by drivers to instrument
-- module internals they do not own; the mod's own code calls now()/add()
-- directly so the label is visible at the site.
function Perf.wrap(tbl, name, label)
local orig = tbl and tbl[name]
if not orig then return false end
tbl[name] = function(...)
if not Perf.enabled then return orig(...) end
local t0 = clock()
local a, b, c, d = orig(...)
Perf.add(label or name, t0)
return a, b, c, d
end
return true
end
-- ------------------------------------------------------------- counters
function Perf.count(name, by)
if not Perf.enabled then return end
Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1)
end
-- --------------------------------------------------------------- frames
--
-- Called once per RENDERED frame (the endFrame seam), not once per
-- logic update: a scripted run can step the game many times per render,
-- and a frame the player never saw cannot have hitched for them.
local lastFrame = nil
function Perf.frame()
if not Perf.enabled then return end
local t = clock()
if lastFrame then
local dt = t - lastFrame
local n = Perf.frameCount + 1
Perf.frameCount = n
Perf.frames[(n - 1) % Perf.RING + 1] = dt
local seg = segmentEntry()
if seg then seg.frames[#seg.frames + 1] = dt end
end
lastFrame = t
end
-- Discard the pending frame stamp: after a long blocking operation the
-- next frame delta would include it and libel the renderer.
function Perf.resync()
lastFrame = Perf.enabled and clock() or nil
end
-- ------------------------------------------------------------ reporting
local function percentile(sorted, p)
local n = #sorted
if n == 0 then return 0 end
local i = math.ceil(p * n)
if i < 1 then i = 1 end
if i > n then i = n end
return sorted[i]
end
-- Frame statistics in MILLISECONDS. p95/p99 rather than the average
-- because smoothness is a tail property: a run that averages 9ms and
-- spikes to 60ms four times reads as stuttering, and its average reads
-- as fine.
function Perf.frameStats(list)
local src = list or Perf.frames
local sorted = {}
for i = 1, #src do sorted[i] = src[i] * 1000 end
table.sort(sorted)
local n = #sorted
local total = 0
for i = 1, n do total = total + sorted[i] end
local over16, over33 = 0, 0
for i = 1, n do
if sorted[i] > 16.7 then over16 = over16 + 1 end
if sorted[i] > 33.3 then over33 = over33 + 1 end
end
return {
n = n,
avg = n > 0 and total / n or 0,
p50 = percentile(sorted, 0.50),
p95 = percentile(sorted, 0.95),
p99 = percentile(sorted, 0.99),
worst = n > 0 and sorted[n] or 0,
over16 = over16,
over33 = over33,
}
end
function Perf.reset()
Perf.labels, Perf.order = {}, {}
Perf.counters = {}
Perf.frames, Perf.frameCount = {}, 0
Perf.segments = {}
Perf.segment = nil
lastFrame = nil
end
local function sortedLabels(store, order)
local out = {}
for _, lbl in ipairs(order) do out[#out + 1] = lbl end
table.sort(out, function(a, b) return store[a].total > store[b].total end)
return out
end
function Perf.printReport(title)
print(("[perf] ==== %s ===="):format(tostring(title or "report")))
local f = Perf.frameStats()
print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d")
:format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33))
for _, seg in ipairs(Perf.segments) do
local s = Perf.frameStats(seg.frames)
print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d")
:format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33))
end
print("[perf] ---- labels (ms, sorted by total) ----")
for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do
local s = Perf.labels[lbl]
print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f")
:format(lbl, s.n, s.total * 1000, s.max * 1000))
end
local names = {}
for k in pairs(Perf.counters) do names[#names + 1] = k end
table.sort(names)
if #names > 0 then print("[perf] ---- counters ----") end
for _, k in ipairs(names) do
print(("[perf] %-46s %d"):format(k, Perf.counters[k]))
end
end
-- ------------------------------------------------------------------ json
--
-- Hand-rolled rather than pulled from the engine: the report has to be
-- readable by a diff tool between two runs, and that means stable key
-- ORDER, which a generic serializer does not promise.
local function q(s)
return '"' .. tostring(s):gsub('[%c"\\]', function(c)
if c == '"' then return '\\"' end
if c == "\\" then return "\\\\" end
return ("\\u%04x"):format(c:byte())
end) .. '"'
end
local function num(x)
return ("%.4f"):format(x)
end
local function statsJson(f)
return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}")
:format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99),
num(f.worst), f.over16, f.over33)
end
local function labelsJson(store, order)
local parts = {}
for _, lbl in ipairs(sortedLabels(store, order)) do
local s = store[lbl]
parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}")
:format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000))
end
return "{" .. table.concat(parts, ",") .. "}"
end
function Perf.toJson(meta)
local parts = {}
parts[#parts + 1] = "{"
parts[#parts + 1] = "\"meta\":{"
local m = {}
for k, v in pairs(meta or {}) do
m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v))
end
table.sort(m)
parts[#parts + 1] = table.concat(m, ",") .. "},"
parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. ","
parts[#parts + 1] = "\"segments\":{"
local segs = {}
for _, seg in ipairs(Perf.segments) do
segs[#segs + 1] = q(seg.name) .. ":{\"frames\":"
.. statsJson(Perf.frameStats(seg.frames))
.. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}"
end
parts[#parts + 1] = table.concat(segs, ",") .. "},"
parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. ","
local cs = {}
for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end
table.sort(cs)
parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}"
parts[#parts + 1] = "}"
return table.concat(parts, "")
end
-- Written through love.filesystem (the save directory) rather than io:
-- a driver run and an Android session both have one, and neither is
-- guaranteed a writable working directory.
function Perf.write(name, meta)
local body = Perf.toJson(meta)
if love and love.filesystem then
pcall(love.filesystem.createDirectory, "ds_bench")
local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body)
if ok then
print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory())
.. "/ds_bench/" .. name .. ".json")
return true
end
end
print("[perf] JSON " .. name .. ": " .. body)
return false
end
-- ----------------------------------------------------------- draw stats
--
-- love.graphics.getStats() resets per frame, so it is only meaningful
-- read at the END of a frame -- which is where Perf.frame() runs.
function Perf.drawStats()
if not (love and love.graphics and love.graphics.getStats) then return end
local s = love.graphics.getStats()
Perf.count("stat.drawcalls", s.drawcalls or 0)
Perf.count("stat.canvasswitches", s.canvasswitches or 0)
Perf.count("stat.shaderswitches", s.shaderswitches or 0)
Perf.count("stat.frames", 1)
Perf.texturememory = s.texturememory
Perf.canvases = s.canvases
Perf.images = s.images
end
return Perf
+88 -2
View File
@@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160
-- surface shadows itself in a moire of acne; too much and a shadow detaches
-- from the foot of what casts it. The frustum is ~400 world pixels deep and
-- the packed depth resolves under 0.01 of one, so there is room.
ShadowMap.BIAS = 1.0
--
-- It cannot be ONE number, because what the comparison has to forgive is
-- not fixed: the map stores one depth for a whole texel, so a lit surface
-- reads its own depth wrong by however far it RAMPS across that texel --
-- the texel's world size times the surface's slope in the light's frame.
-- The texel swings from a third of a world pixel at the closest zoom to
-- well over one at a maximised window on the widest, so a constant bias is
-- generous at one end of the ladder and short at the other. Short shows up
-- as diagonal bands of acne across big lit surfaces -- diagonal because
-- the moire runs along neither the world grid nor the screen's, but along
-- the depth ramp in the sun's own frame, and the sun sits southeast.
--
-- So: a floor for what does not scale (the packed depth's quantisation,
-- and the two passes reaching the same world point by different matrices),
-- plus a term in texels for what does.
ShadowMap.BIAS = 0.5
-- World pixels of slack per world pixel of texel, for the steepest LIT
-- surface here: a roof pitched 45 degrees and turned away from the sun,
-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of
-- the light frame's two axes (a vertical wall, by comparison, manages 1.7,
-- flat ground 0.7, and anything steeper than that roof has its back to the
-- sun and never reads the map at all). The 2x2 filter's taps sit half a
-- texel out on both axes at once, so the worst a tap can disagree by is
-- half the ramp along each -- which is where the halving that turns 6.2
-- into 3.1 comes from, and why it is the SUM of the two components rather
-- than their magnitude.
--
-- Measured against the artefact rather than trusted: the probe
-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit
-- surfaces, and the banding stops at slack ~2.4 world px on the widest
-- rung -- where this lands 3.1 * 0.83 + 0.5.
ShadowMap.SLOPE = 3.1
-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel.
-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth
-- the map actually stores.
ShadowMap.slack = ShadowMap.BIAS
local SHADER = [[
varying float vDepth;
@@ -316,9 +353,58 @@ local function fit(cx, cy, vw, vh)
-- what the frustum ended up covering, for probes: the lateral extent in
-- world pixels divided by RES is how fine a shadow edge can land
ShadowMap.extent = { r - l, t - b, far - near }
-- the slack the comparison needs, against the coarser of the two texel
-- axes (the box is asymmetric, and one number has to cover both)
ShadowMap.slack = ShadowMap.BIAS
+ ShadowMap.SLOPE * math.max(w, h) / res
-- the stored depth spans the frustum, so a world-pixel bias is that
-- fraction of it
ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near)
ShadowMap.bias = ShadowMap.slack / math.max(1, far - near)
end
-- How much of the compare's forgiveness a snugged caster takes back, 0..1.
-- Short of 1 on purpose: at exactly 1 the card's own fragments compare
-- against their own stored depth on a float-equality knife edge and can
-- speckle. The tenth left over is dozens of times the packed depth's
-- quantization -- ample for that -- and leaves the contact gap around a
-- quarter of a world pixel at any sun, which no zoom resolves.
ShadowMap.SNUG = 0.9
-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is
-- drawn into the map.
--
-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term)
-- so lit surfaces do not acne against their own texels -- but that same
-- forgiveness is what lets the ground right next to a standing figure read
-- as lit: a receiver within `slack` of its blocker along the ray passes the
-- test, so the first stretch of every shadow is forgiven away and on screen
-- it starts that far from the feet, further the lower the sun. The classic
-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at
-- a day/night golden hour or under the moon.
--
-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow
-- falls -- every point stays on the same light ray -- but moving it toward
-- the sun stores it SHALLOWER, so a ground point right at the foot is
-- already `slack` deeper than the stored blocker and fails the lit test:
-- the root lands back under the feet. Nothing else is touched -- no
-- terrain moved, so the acne margin the slack exists for is intact where
-- it matters. For sprite cards and other thin stand-ins only.
--
-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same
-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`).
-- Stored and lookup then agree exactly, as they did before snugging, and
-- the compare keeps its full acne margin. A caster stored snugged but read
-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal
-- moire bands crawling across the card.
--
-- Valid between begin() and the next begin(): `slack` and the sun hold
-- still between redraws of the map, so a lit frame that reuses last
-- frame's map computes the same displacement it was stored with.
function ShadowMap.snug(model)
local f = sunDir()
local s = -ShadowMap.slack * ShadowMap.SNUG
return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s),
model or IDENTITY)
end
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
+466
View File
@@ -0,0 +1,466 @@
-- The sky, generated rather than shipped.
--
-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void
-- behind it is sky rather than a black plate: at 75 degrees the horizon is
-- genuinely in frame and the bands run down to meet it, and at the steeper rungs
-- the void that shows is the ground running out past the map edge, which gets
-- the same sky above the same haze. A battle's placed camera keeps the flat fill
-- it has always had -- its horizon is above the frame and its look is not this
-- rung's to change.
--
-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat
-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band
-- dithered into the bottom of each one. Alternating two colours on a pixel grid
-- is how a machine with four colours to a palette got a fifth, sixth and seventh
-- out of them, and it is what keeps four bands reading as a gradient rather than
-- as four stripes. No clouds, nothing moving.
--
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
-- back up: one full-region rectangle through a shader that answers every pixel
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
-- displayed at, so there is nothing for a filter to soften and nothing to go
-- stale when the window or the zoom changes. The shader does bind one texture,
-- but it is a palette rather than an image -- the bands, one texel each, sampled
-- nearest (see rampFor, and why it is not a uniform array).
--
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band
-- edges land on the same grid the world's own texels do, and a ZOOM keypress is
-- reflected in the frame that follows it rather than whenever something else
-- happened to rebuild.
--
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
-- is shade order: a display mode transforms a four-colour palette by replacing it
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
-- written light to dark. So the sky reads the list backwards -- deepest shade
-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
-- nothing.
--
-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
-- (four phase palettes, blended along the clock and re-quantised to the
-- lattice), and this file paints whatever the clock says: blue at noon, gold
-- and violet through the twilights -- warmed further around the low sun by a
-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
-- hang here too: cell-art discs on the same grid as the dither, scissored to
-- the sky's own region so a setting body slips below the horizon point and is
-- gone, never wandering under the map.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Sky = {}
-- The most bands a phase palette may paint with. Eight leaves headroom over
-- DayNight's six-band ones without paying for more; the ramp the shader reads
-- them from is built at the width actually used, so the cap costs nothing.
Sky.MAX_BANDS = 8
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
-- leaves the top of each band flat -- a band dithered all the way through reads
-- as one averaged colour instead of as a step with a soft bottom edge.
Sky.DITHER = true
Sky.DITHER_START = 0.6
-- How much of the frame the bands cover when the horizon is NOT in it, as a
-- fraction of the canvas height.
--
-- At the steeper rungs the camera looks down far enough that the ground plane's
-- vanishing line is above the top edge -- there is no horizon to hang the pale
-- end on, but there is still void up there where the map runs out, and it should
-- read as sky. So the bands take the same slice of the frame the top rung's own
-- horizon gives them, which keeps the sky looking like one sky across the whole
-- ladder instead of changing character rung by rung.
Sky.SPAN = 0.23
-- ------- the bands
--
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
--
-- Memoised, because this runs once a frame and the answer only moves when the
-- mode does.
local cache = { bands = nil, key = {}, ramp = nil }
function Sky.bands()
local pal = DayNight.palette()
local shades = PaletteFX.effectiveColors(pal) or pal
local n = math.min(#shades, #pal, Sky.MAX_BANDS)
local key, k = cache.key, 0
local same = cache.bands ~= nil and #cache.bands == n
for i = 1, n do
local c = shades[i]
for ch = 1, 3 do
k = k + 1
if key[k] ~= c[ch] then same = false end
key[k] = c[ch]
end
end
if same then return cache.bands end
-- the ramp is these bands as a texture (see rampFor); a new list is a new
-- ramp, and the old one is nothing's to keep
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
local bands = {}
for i = 1, n do
-- backwards: the palette's darkest rung is the top band
local c = shades[n - i + 1]
bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
cache.bands = bands
return bands
end
-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
-- bottom edge and the right flat fill for any outdoor void that wants to
-- match the clock without painting bands (the battle arena's backdrop).
function Sky.haze()
local bands = Sky.bands()
return bands and bands[#bands] or nil
end
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
-- making it the bottom band's own colour means the haze below the sky and the
-- bottom of the sky are one colour -- the join has no seam, and a frame that
-- cannot paint the bands is a hazy sky rather than a wrong one.
--
-- Mutates the descriptor, which is a fresh table per frame from its caller.
function Sky.dress(sky)
local bands = Sky.bands()
local haze = bands and bands[#bands]
if not (sky and haze) then return sky end
sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3]
sky.bands = bands
return sky
end
-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon
-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil
-- when there is no room for any of it.
function Sky.region(h, horizonY)
if not (h and h > 0) then return nil end
local edge = horizonY
if not (edge and edge > 0) then edge = h * Sky.SPAN end
edge = math.min(edge, h)
if edge < 1 then return nil end
return edge
end
-- ------- the pass
--
-- One rectangle, one shader. Every pixel answers for itself from its canvas
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
-- -- there is no image being scaled and so nothing to be soft. The one texture
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
-- sampled nearest, one lookup per pixel (see rampFor).
--
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
-- by is the top of its own cell row, so a whole cell row is one colour and every
-- edge in the sky lands on the diorama's pixel grid.
local SHADER_SRC = [[
uniform Image ramp; // the bands, one texel each, top of the sky first
uniform float count; // how many texels wide that ramp is
uniform float edge; // the sky's bottom, in canvas pixels
uniform float cell; // the diorama's pixel size, in canvas pixels
uniform float start; // where the checker begins inside a band
uniform float alpha;
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
uniform vec2 glowPos; // the sun disc, in canvas pixels
uniform float glowInvR; // 1 / the glow's reach
uniform vec3 glowColor;
// Band `i`, read from its own texel centre. The index is clamped rather than
// trusted: `pos` below can land exactly on `count` when the arithmetic is
// carried at mediump -- which is the fragment default on GLSL ES -- and a
// sample past the last band must be the last band, not whatever is off the
// end of the image.
vec3 bandAt(float i) {
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float row = floor(sc.y / cell) * cell; // top of this cell row
float pos = min(row / max(edge, 1.0), 1.0) * count;
float base = min(floor(pos), count - 1.0);
vec3 c = bandAt(base);
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (base < count - 1.0 && (pos - base) > start) {
if (parity < 0.5) { c = bandAt(base + 1.0); }
}
// The sunset's warmth, radiating from the disc: posterised to a few rungs
// and checker-dithered between them -- the same 8-bit move as the bands,
// so the glow reads as painted light rather than as a smooth airbrush --
// and measured cell-to-cell, so its rings ride the diorama's own grid.
if (glowAmt > 0.0) {
vec2 cc = (floor(sc / cell) + 0.5) * cell;
float d = length(cc - glowPos) * glowInvR;
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
float lvl = floor(g * 4.0);
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
}
return vec4(c, alpha);
}
]]
-- ------- the ramp
--
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
-- read through a loop counter. On desktop GL that is as portable as it looks.
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
-- strip running from partway down the gradient to the horizon point, with the
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
-- palest band again, but delivered by love.graphics.clear instead of by the
-- array) landing in exactly the right colour. Same colour, two routes, one of
-- them black: the fault was the array, not the palette.
--
-- Which of the ES failure modes it was hardly matters -- a driver that
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
-- because they all have the same shape: slots past the first few read as zero,
-- and zero is black.
--
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
-- there is no array to index and no budget to overrun, and a texel that does
-- not exist cannot read as black because the image is built at exactly the
-- width the shader divides by. Nearest and clamped, so a sample lands on one
-- band's own colour and an out-of-range one lands on the end band rather than
-- on nothing.
--
-- Rebuilt only when the bands move, which is when the clock or the display
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
local function rampFor(bands)
if cache.ramp and cache.rampFor == bands then return cache.ramp end
if not (love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then return nil end
local n = #bands
if n < 1 then return nil end
local ok, data = pcall(love.image.newImageData, n, 1)
if not (ok and data) then return nil end
for i = 1, n do
local c = bands[i]
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
end
local built, img = pcall(love.graphics.newImage, data)
if not (built and img) then return nil end
-- nearest: a band is a flat colour, not something to interpolate between.
-- clamp: the shader clamps its index too, so this is the second of two
-- guards against ever sampling off the end -- and it returns the edge band.
pcall(img.setFilter, img, "nearest", "nearest")
pcall(img.setWrap, img, "clamp", "clamp")
cache.ramp, cache.rampFor = img, bands
return img
end
Sky._rampFor = rampFor -- named for the suite
local shader = nil -- nil = untried, false = unavailable
local function getShader()
if shader == nil then
shader = false
if love.graphics and love.graphics.newShader then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
if ok and sh then
shader = sh
elseif V and V.mod and V.mod.log then
-- once, and only where it can be read: the fallback below is a sky
-- without its dither, which is easy to look at and impossible to
-- diagnose without this line
V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, "
.. "with no dither between them", tostring(sh))
end
end
end
return shader or nil
end
Sky._getShader = getShader -- named for the suite
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
-- quantised edges. For a driver that could not compile the shader -- which is
-- also every headless run.
local function paintFlat(w, h, bands, edge, alpha, cell)
local g = love.graphics
local n = #bands
local prev = 0
for i = 1, n do
local cut = (i == n) and math.min(h, math.ceil(edge))
or math.floor(i / n * edge / cell + 0.5) * cell
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
if cut > prev then
local c = bands[i]
g.setColor(c[1], c[2], c[3], alpha)
g.rectangle("fill", 0, prev, w, cut - prev)
end
prev = cut
end
end
-- ------- the discs
--
-- The sun and moon, as cell art: a circle of whole diorama cells with a
-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
-- through the same display-mode transform as every palette here, and
-- SCISSORED to the sky's region: the horizon point is where a setting body
-- disappears, so it can never hang under the map at a high pitch.
--
-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
-- an angle, so zooming the ground in and out must not swell and shrink the
-- sun with it. The radius is a fraction of the frame height, converted to
-- whole cells so the disc still sits on the diorama's grid -- chunky cells
-- up close, fine ones at survey zoom, the same size body either way.
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
-- crater centres as fractions of the radius, so they ride any disc size
local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
{ -0.15, 0.7 }, { 0.05, 0.05 } }
local function paintDisc(body, edge, cell, w, h)
local g = love.graphics
if not (body and body.y and g.setScissor) then return end
local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
local shades = PaletteFX.effectiveColors(src) or src
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
local r = math.max(Sky.DISC_MIN,
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
-- the low sun looms: the classic sunset exaggeration, and it reads
if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
-- snap the centre to the cell grid, like everything else in this sky
local bx = math.floor(body.x / cell) * cell + cell / 2
local by = math.floor(body.y / cell) * cell + cell / 2
if by - r * cell > edge then return end -- wholly below the horizon point
local core = shades[1]
local main = shades[twilight and 3 or 2]
local sx, sy, sw, sh = g.getScissor()
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
local craterR = math.max(1, math.floor(r / 5))
for dy = -r, r do
for dx = -r, r do
local d = math.sqrt(dx * dx + dy * dy)
if d <= r + 0.1 then
local c = d <= r * 0.5 and core or main
-- dithered rim: the outer ring keeps only one parity of its cells
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
if body.moon then
for _, cr in ipairs(MOON_CRATERS) do
local cdx = dx - math.floor(cr[1] * r + 0.5)
local cdy = dy - math.floor(cr[2] * r + 0.5)
if cdx * cdx + cdy * cdy <= craterR * craterR then
c = shades[3]
end
end
end
if keep then
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
g.rectangle("fill", bx + dx * cell - cell / 2,
by + dy * cell - cell / 2, cell, cell)
end
end
end
end
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
g.setColor(1, 1, 1, 1)
end
-- Paint the sky into the bound canvas, filling it from the top edge down to
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
--
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
--
-- `body` is the sun or moon to hang, already projected to canvas pixels by
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
-- along; nil hangs nothing and warms nothing.
--
-- Returns false when there is nothing to paint, in which case the caller's flat
-- fill is the whole sky. That fill is the palest band, so a frame that declines
-- this looks like a hazy day rather than like a bug.
function Sky.paint(w, h, sky, horizonY, cell, body)
local bands = sky and sky.bands
if not (bands and bands[1]) then return false end
if not (w and h and w > 0 and h > 0) then return false end
local g = love.graphics
if not (g and g.rectangle) then return false end
local edge = Sky.region(h, horizonY)
if not edge then return false end
local alpha = sky[4] or 1
cell = math.max(1, math.floor((cell or 1) + 0.5))
-- State to put aside. The scene's shader is one, and the blend mode another --
-- a pass that left "replace" behind would make the fade-in strength meaningless
-- -- but the DEPTH MODE is the one that would break the frame: a rectangle
-- drawn under the pass's own ("lequal", true) stamps itself across the depth
-- buffer at the near plane and hides the entire world behind the sky.
local prevShader = g.getShader and g.getShader() or nil
local cmp, write
if g.getDepthMode then cmp, write = g.getDepthMode() end
if g.setDepthMode then g.setDepthMode("always", false) end
local blend, blendAlpha
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
if g.setBlendMode then g.setBlendMode("alpha") end
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
local sh = getShader()
local ramp = sh and rampFor(bands)
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
if sh then
local sent = pcall(function()
-- the bands arrive as a texture, one texel each, and `count` is that
-- texture's width -- see rampFor for why they are not a uniform array
sh:send("ramp", ramp)
sh:send("count", #bands)
sh:send("edge", edge)
sh:send("cell", cell)
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
sh:send("alpha", alpha)
sh:send("glowAmt", glowAmt)
if glowAmt > 0 then
local gc = body.glowColor or { 248, 224, 168 }
sh:send("glowPos", { body.x, body.y })
sh:send("glowInvR", 1 / math.max(1, w * 0.55))
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
end
end)
if sent then
g.setShader(sh)
g.setColor(1, 1, 1, 1)
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
g.setShader()
else
sh = nil
end
end
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
-- the disc goes over the glow, under nothing: plain rectangles, so it is
-- there whether or not the shader built
paintDisc(body, math.min(h, edge), cell, w, h)
g.setColor(1, 1, 1, 1)
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end
if prevShader and g.setShader then g.setShader(prevShader) end
return true
end
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
-- context builds a new one instead of drawing with a handle from the old. The
-- ramp is a GPU object on the same context and goes with it.
function Sky.invalidate()
shader = nil
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
end
return Sky
+239 -21
View File
@@ -55,6 +55,14 @@ local Structures = {}
-- must match ChunkMesher's ring (3 border blocks, in tiles)
local RING = 12
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery. (Declared up here rather than
-- beside buildCylinders because forMap's grid resolve reads it too.)
local ROUND_RING = 4
-- object-mode gates
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
@@ -154,12 +162,34 @@ function Structures.forMap(map)
local TileRenderer = require("src.render.TileRenderer")
local borderId = TileRenderer.borderBlockFor(map)
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
-- TREES fill stops at ROUND_RING instead of running the full RING.
-- Only that far out does a tree cell get carved into a hull; past it
-- the cells fall through to the mesher's plain box, and a slab of
-- flat-topped boxes beside the modelled wall reads as a painted-on
-- plateau -- the wall looking like it was cut off with scissors. So
-- the far ring is simply not built: beyond ROUND_RING tileLookup
-- answers nil, which is the same "nothing out there" BLACK already
-- produces and every pass below already copes with. The cut lands on
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
-- of 4 tiles, so no group is left half-resolved at the edge.
--
-- WATER and the other tilesets' own borders keep the full ring: a flat
-- sheet of water is what water looks like from above anyway, and an
-- interior's border is black already.
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
and (TileRenderer.voidFill or "trees") == "trees"
local tw2, th2 = tw, th
local function tileLookup(tx, ty)
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
return map:tileAt(tx, ty)
end
if not borderBlk then return nil end
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
or tx >= tw2 + ROUND_RING
or ty >= th2 + ROUND_RING) then
return nil
end
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
end
local shapeAt, tileAt = {}, {}
@@ -192,8 +222,9 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {} }
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -268,6 +299,14 @@ function Structures.forMap(map)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
@@ -880,13 +919,6 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
return quads, bg
end
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery.
local ROUND_RING = 4
-- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set):
-- the same four-tile tree repeats for hundreds of cells on a map and
-- across every route of its tileset, so the carve runs once per distinct
@@ -1214,6 +1246,9 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
end
function Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
-- Read once: it is a property of the tileset, not of the column.
local backfill = TileShape.bookcaseBackfill(map.tileset.id)
for tx = x0, x1 do
local ty = y1
while ty >= y0 do
@@ -1241,10 +1276,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1)
capTile = S.tileAt[ck]
end
end
-- The box is one cell deep, so it covers only the run's southmost
-- rows; everything north of that is vacated. By default a vacated
-- row is skipped and painted with synthesized ground -- right for a
-- 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.
local covered = math.min(2, front - top + 1)
local srcK = keyOf(tx, top - 1)
local src = backfill == "above" and S.shapeAt[srcK] or nil
for cy = top, front do
local tk = keyOf(tx, cy)
S.skip[tk] = true
S.ground[tk] = false
if src and cy <= front - covered then
S.shapeAt[tk] = src
S.tileAt[tk] = S.tileAt[srcK]
else
S.skip[tk] = true
S.ground[tk] = false
end
end
bookcaseRank(S, map, tx, top, front, capTile)
front = top - 1
@@ -1700,18 +1749,44 @@ function Structures.extractObjects(S, map, region, data, perRow, force)
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
strict = fs ~= nil and fs.class == "cutout"
end
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
-- prop whose body reaches its own edge votes itself out. The Center's
-- potted plants are the case: the pot's olive base is drawn flush on the
-- bottom row of the block, so "dark" came back as background and every
-- dark pixel in the whole plant drained with it -- the pots rendered as
-- hollow black frames while the 2D art has solid olive bodies.
--
-- Where the vote misreads the art, the profile can name the background
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
-- per tileset, because the answer is per drawing: the healing consoles'
-- screens really do stand on a dark wall band and really do need dark
-- voted out, and the PC really does need light kept.
local bg = {}
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
do
local named = TileShape.propBg(map.tileset.id)
if named then
for _, c in ipairs(region.tiles) do
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
if rule then
for shadeName in pairs(rule) do bg[shadeName] = true end
break
end
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
if not next(bg) then
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
end
for i, st in pairs(state) do
if strict then
if st == "dark" or st == "light" then
@@ -1943,8 +2018,13 @@ function Structures.buildObject(S, map, region, cluster,
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
math.floor(cluster.maxY / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
-- `bookcase` supports as well as `upright`. A prop drawn above an
-- authored box stands ON it whatever art the box renders with, and a
-- stacked box is still a box: the Plateau's gate pilasters carry a
-- statue on 48 of their tops, and collapsing the pilaster to a stacked
-- run made every one of them fail this test and drop to ground level.
if blocked and bs and bs.authored and (bs.h or 0) > 0
and (bs.art == "upright" or bs.art == "bookcase") then
baseY, support = bs.h, bs
end
end
@@ -2078,7 +2158,8 @@ function Structures.buildObject(S, map, region, cluster,
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
if support and support.class == "wall" then
if support and (support.class == "wall" or support.class == "cliff"
or support.art == "bookcase") then
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
-- plinth) is a statue on a pillar with ONE cell of footprint: the
-- block below already carries the whole base, so the drawn cell
@@ -2086,6 +2167,13 @@ function Structures.buildObject(S, map, region, cluster,
-- the base backwards. Furniture supports (a monitor on its desk)
-- keep the box-extension below -- their drawn cell is the
-- furniture's own upper rows, and floor there would amputate it.
--
-- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
-- full-height blocks like `wall` and belong here, while `desk` is
-- 24px and still furniture. The Plateau's statues on stacked
-- pilasters found this -- taking the furniture branch turned each
-- statue's own two rows into a 32px box wearing the pilaster's art,
-- so every one of them stood inside a slab of its own plinth.
S.skip[k] = true
S.ground[k] = best
elseif support then
@@ -2116,6 +2204,136 @@ function Structures.buildObject(S, map, region, cluster,
return true
end
-- ---- figures: a person drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position.
--
-- The mask IS the classification: no flood, no shade segmentation, no
-- validation gate. Every automatic route in this file asks the art where
-- the object ends, and a figure painted into its own furniture has no
-- answer to give -- so the profile answers instead, and this only has to
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
--
-- A figure is a SPRITE, not a prop. It gets exactly the treatment
-- SpriteBillboards gives a character: one flat plane of the drawing's own
-- pixels, no thickness, standing at its feet and leaned back by the
-- camera's pitch at draw time so it always reads face-on -- because that
-- is what the artwork is. A seated man drawn face-on is a 2D icon like
-- every other Gen 1 figure; extruding him into a slab reconstructs a body
-- nobody drew (the ten-voxel version read as a wedge of furniture, and
-- even one voxel showed an edge the sprites never show).
--
-- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
-- from. One quad per pixel rather than one alpha-keyed texture: the
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
-- same silhouette straight out of the live atlas, so every palette bake
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return fig.mask[ly * bw + lx] or false
end
-- his feet and his west edge: the card's own origin
local lowY, minX = 0, bw - 1
for ly = 0, bh - 1 do
for lx = 0, bw - 1 do
if at(lx, ly) then
if ly > lowY then lowY = ly end
if lx < minX then minX = lx end
end
end
end
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one.
local baseY = 0
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY = bs.h
end
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = fig.tiles[math.floor(ly / 8) * fig.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = lx - minX, lowY - ly
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
u = u, v = v, shade = 1 }
end
end
end
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet are
-- drawn in, which is the same convention a character card uses (its feet
-- plane sits at its cell's middle) -- so he sorts against the couch and
-- against a player walking past exactly the way an NPC standing there
-- would.
S.figures[#S.figures + 1] = {
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY,
}
-- What each covered tile wears now that he is off it. Only the ART
-- changes: the couch tiles keep their `counter` box (they ARE the
-- couch) and the floor tiles he overhung stay flat floor -- the
-- profile just names the version of each drawing without him in it,
-- so nothing has to be synthesized or repainted from a neighbour vote.
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
end
end
-- Every authored figure, wherever the map draws it.
--
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
-- places this couch once in each of the eleven Pokemon Centers (and the
-- Celadon Hotel), so the pattern finds all of them without the profile
-- naming a single map or cell. The repaint above replaces the pattern's
-- own tiles, so a match can never fire twice on the same drawing.
function Structures.buildFigures(S, map, x0, x1, y0, y1)
local figures = TileShape.figures(map.tileset.id)
if not figures then return end
local perRow = map.tileset.tilesPerRow or 16
for _, fig in ipairs(figures) do
for ty = y0, y1 - fig.h + 1 do
for tx = x0, x1 - fig.w + 1 do
Budget.tick()
local hit = true
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
hit = false
break
end
end
if hit then buildFigure(S, map, fig, tx, ty, perRow) end
end
end
end
end
-- ---- tall grass ----
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
+6 -1
View File
@@ -270,7 +270,12 @@ local function readback(image)
local prev = love.graphics.getCanvas()
local ok, data = pcall(function()
local w, h = image:getDimensions()
local canvas = love.graphics.newCanvas(w, h)
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
-- so the atlas would be drawn into a texture 2.75x its size and read
-- back magnified -- and every tile coordinate below, which counts in
-- eights from the top-left, would land somewhere between two tiles.
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
-- straight copy: no blending against the cleared target, no tint from
+189 -17
View File
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
sign = 12,
wall = 16,
tree = 16,
-- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
-- badge-check gates down Route 23 are drawn 32px, the same height as a
-- statue on its plinth, and read as a step in the terrain rather than a
-- room's wall. Same fold as `wall`, twice the height -- and its own
-- class because `wall` is 16px for every interior in the game.
cliff = 32,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
@@ -110,6 +116,7 @@ local ART = {
ledge = "top",
roof = "top",
wall = "upright",
cliff = "upright",
tree = "upright",
fence = "upright",
sign = "upright",
@@ -168,6 +175,8 @@ local ART = {
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
local figCache = {} -- tileset id -> parsed figure masks, or false
local bgCache = {} -- tileset id -> prop background shades, or false
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
-- through the mod's own file loader rather than package.path: a mod's
@@ -224,29 +233,42 @@ end
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
-- the map and coordinates are in hand. First match wins; no match keeps
-- the tile's ordinary pin.
-- `when_below` is the mirror, and it exists because ABOVE is not always the
-- side that tells the two uses apart. The Plateau's $0D is the case: it is
-- the gate wall's top band AND the base course under a column of rock face,
-- and scanned over both maps the tile above is $03 for 64 of the first and
-- 140 of the second -- no rule on `above` can split them. What is BELOW
-- does, exactly: the wall's own face $0F sits under the top band and under
-- nothing else (336 vs 352, clean).
local function authoredConditions(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local spec = entry and entry.when_above
if type(spec) ~= "table" then return nil end
if type(entry) ~= "table" then return nil end
local out, any = {}, false
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule.above) == "table" then
local set = {}
for _, t in ipairs(rule.above) do set[t] = true end
list[#list + 1] = { above = set, class = rule.class }
local function collect(spec, side)
if type(spec) ~= "table" then return end
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = out[tile] or {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule[side]) == "table" then
local set = {}
for _, t in ipairs(rule[side]) do set[t] = true end
list[#list + 1] = { side = side, set = set, class = rule.class }
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
end
collect(entry.when_above, "above")
collect(entry.when_below, "below")
return any and out or nil
end
@@ -348,9 +370,12 @@ function TileShape.at(map, shapes, tile, tx, ty)
-- tile and the cell rules below (see authoredConditions)
local rules = shapes.cond and shapes.cond[tile]
if rules then
local above = map:tileAt(tx, ty - 1)
for _, rule in ipairs(rules) do
if above and rule.above[above] then
-- NOTE map:tileAt border-EXTENDS: one row off an edge answers the
-- map's borderBlock, never nil. A rule listing whatever that block
-- draws will fire along that whole edge (it did, on the Marts).
local n = map:tileAt(tx, rule.side == "above" and ty - 1 or ty + 1)
if n and rule.set[n] then
-- shapes.condShape, NOT shapes.classes: the canonical class
-- shapes are SHARED, and `wall` in particular is the very object
-- rule 4 hands every unauthored solid tile. Marking that one
@@ -369,11 +394,158 @@ function TileShape.at(map, shapes, tile, tx, ty)
return s
end
-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture,
-- cut out by an explicit pixel mask and stood up on top of it.
--
-- Every other route in this file resolves a whole 8x8 TILE, which is
-- exactly why none of them can reach a figure that shares its tiles with
-- the thing it sits on -- and the detector's segmentation cannot either
-- when the drawing has no background margin to flood from and wears the
-- same shades as its furniture. So the profile authors the silhouette
-- pixel by pixel (see data/voxel_heights.lua):
--
-- figures = { { w = <tiles across>,
-- tiles = { ...w*h tile ids, row-major... },
-- under = { ...w*h ids: what each tile wears once the
-- figure is lifted off it... },
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
-- figure... } } }
--
-- No class: a figure is always a flat sprite card, drawn the way
-- SpriteBillboards draws a character (see Structures.buildFigures).
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
function TileShape.figures(tilesetId)
local hit = figCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.figures
local out = {}
if type(list) == "table" then
for _, f in ipairs(list) do
local ok = type(f) == "table" and type(f.w) == "number"
and type(f.tiles) == "table" and type(f.under) == "table"
and type(f.pixels) == "table"
local w = ok and math.floor(f.w) or 0
local h = (w >= 1) and (#f.tiles / w) or 0
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
and #f.under == #f.tiles and #f.pixels == h * 8
if ok then
for i = 1, h * 8 do
local row = f.pixels[i]
if type(row) ~= "string" or #row ~= w * 8 then
ok = false
break
end
end
end
if ok then
local mask, n = {}, 0
for ly = 0, h * 8 - 1 do
local row = f.pixels[ly + 1]
for lx = 0, w * 8 - 1 do
if row:sub(lx + 1, lx + 1) ~= "." then
mask[ly * (w * 8) + lx] = true
n = n + 1
end
end
end
if n > 0 then
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
end
end
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
--
-- Structures normally votes on this by reading the shades that touch the
-- drawing's own bounding box, which is right whenever the drawing has a
-- margin of floor around it and wrong when it does not: a prop whose body
-- reaches its own edge votes itself out. Naming the shades is the override,
-- and it is keyed by TILE because the answer is per drawing rather than per
-- tileset -- two props in one atlas can want opposite calls on the same
-- shade (see the POKECENTER entry).
--
-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } }
--
-- Only the four GB shade names exist; anything else is dropped, so a typo
-- degrades to the ordinary vote rather than emptying the background.
local SHADES = { black = true, dark = true, light = true, white = true }
function TileShape.propBg(tilesetId)
local hit = bgCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.prop_bg
local out, any = {}, false
if type(list) == "table" then
for _, rule in ipairs(list) do
if type(rule) == "table" and type(rule.tiles) == "table"
and type(rule.shades) == "table" then
local set, n = {}, 0
for _, name in ipairs(rule.shades) do
if SHADES[name] then
set[name] = true
n = n + 1
end
end
if n > 0 then
for _, t in ipairs(rule.tiles) do
if type(t) == "number" then
out[t] = set
any = true
end
end
end
end
end
end
bgCache[tilesetId] = any and out or false
return bgCache[tilesetId] or nil
end
-- What a bookcase rank does with the rows it VACATES -- the ones behind the
-- one-cell-deep box it collapses onto (a tileset entry's
-- bookcase_backfill). Returns the mode name, or nil for the default.
--
-- "above" hand them the cell immediately above the run: its shape and
-- its art. A wall set INTO a terrace wants this -- the ground
-- behind it is more terrace, not a trench.
-- nil skip them and paint the map's commonest ground underneath,
-- which is right for a free-standing shelf against a wall.
--
-- Per tileset because it is a statement about what the drawing depicts, and
-- the answer differs: the Mart's racks and Red's shelves stand in a room,
-- the Plateau's gate walls are cut into a hillside.
function TileShape.bookcaseBackfill(tilesetId)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local mode = entry and entry.bookcase_backfill
return mode == "above" and mode or 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()
spec = nil
cache = {}
figCache = {}
bgCache = {}
end
return TileShape
+206 -2
View File
@@ -30,6 +30,9 @@ local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local GlassMask = V.require("GlassMask")
local Voxel3D = {}
@@ -200,6 +203,13 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
uniform float glassPhase; // the glint's phase: advances with TRAVEL
uniform float glassGlint; // and its strength: 0 while standing still
uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass)
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
@@ -207,12 +217,44 @@ 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;
vec3 rgb = p.rgb * vShade * sunlight(vSun);
// 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;
#ifdef VOXEL_GRID
// darken what is there rather than painting a colour, so a seam across
// dark grass and one across a white roof each stay in their own palette
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
#endif
// WINDOW GLASS, marked per atlas texel by the mask (see GlassMask).
// By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES
// -- the phase is fed by the camera's own travel and the strength dies
// within a beat of standing still, because a reflection is something
// the viewpoint does: still camera, still glass. It lifts the texel
// toward sky-white and leaves the art visible through it. After dark
// the pane is LIT: the texel's own shine pattern carried into a warm
// lamp colour, replacing the shaded answer above -- so a lit window
// ignores the sun, every shadow and the hour's tint, exactly as a
// window with a lamp behind it does.
// glassOn gates the whole thing per DRAW: the mask is shaped like the
// tileset atlas, and only meshes textured FROM that atlas may consult
// it -- a character samples its own sprite sheet, whose coordinates
// land on the mask's pane rectangles by accident and would stripe the
// cast with lamplight at night.
float glass = Texel(glassMask, tc).a * glassOn;
if (glass > 0.0) {
// the sweep lives in the PANE's own space (atlas texels), not the
// screen's: a pattern anchored to the screen has the world sliding
// through it at zoom speed whenever the camera pans, which strobed --
// worst where the pan and the phase ran opposite ways. Anchored to
// the glass, panning moves nothing; only the phase does, a fraction
// of a texel per step, the same in every walking direction.
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
@@ -344,6 +386,9 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
if cam then
local eye, focus = cam.eye, cam.focus
Voxel3D.eye = eye
-- kept beside the eye for horizonY: where the sky's pale end goes is a
-- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
@@ -369,6 +414,7 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye
Voxel3D.focus = focus
-- perpendicular to the view direction in the YZ plane: north is screen-up
-- when looking straight down, +Y is screen-up when looking level. Never
-- parallel to the view direction, so there is no degenerate a = 0 case.
@@ -386,6 +432,92 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
end
-- ------- the horizon
--
-- Where the ground plane's vanishing line lands, in canvas pixels down from the
-- top edge, or nil when this camera has no horizon to find.
--
-- Not a fraction picked by eye. A direction ALONG the ground is a point at
-- infinity, and putting one through the same matrix the geometry is drawn with
-- gives the line every ground plane in the scene converges on -- so the sky's
-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides
-- the camera tween instead of having to be retuned against it.
--
-- The world CURVE is not in it, and cannot be: it bends distant ground down in
-- the vertex shader, so the ground's apparent edge sits BELOW this line by
-- however much the bend took. What shows in between is the haze the sky's fill
-- already is, which is what a curved-away horizon should look like.
--
-- nil in two cases, both meaning "no horizon in this frame": a camera looking
-- straight down, whose forward direction has no horizontal part to send to
-- infinity, and one whose vanishing line is behind it.
function Voxel3D.horizonY(h)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
-- a DIRECTION, so its w is zero and the matrix's translation column drops
-- out; the clip-space Y flip is already baked into m, so this comes out in
-- canvas coordinates rather than needing one
local y = m[5] * dx + m[7] * dz
local w = m[13] * dx + m[15] * dz
if w <= 1e-6 then return nil end
return (y / w * 0.5 + 0.5) * h
end
-- ------- the hour's light
--
-- What the scene shader multiplies every surface by (see dayTint in the
-- shader). Set per pass by whoever knows what map is being drawn --
-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
-- outdoors" is the map's question, not this pass's. Neutral until somebody
-- answers it, so a caller that never does draws exactly what it always drew.
Voxel3D.tint = { 1, 1, 1 }
-- 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
-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect.
Voxel3D.glassMask = nil
Voxel3D.glassNight = 0
-- the glint, fed by the camera's TRAVEL rather than by a clock (see
-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and
-- the strength is 0 whenever the view has been still for a beat
Voxel3D.glassPhase = 0
Voxel3D.glassGlint = 0
-- The sun or moon disc's place on this camera's canvas, or nil when the
-- body is set, on the southern half of the sky, or behind the camera.
--
-- The direction comes from DayNight (true bearing, squashed elevation) and
-- goes through the SAME matrix the geometry is drawn with, as a point at
-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
-- azimuth is honest: it stands over the point on the horizon its shadows
-- point away from, at every pitch, fov, window shape and zoom.
--
-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
function Voxel3D.skyBody(w, h)
local m = Voxel3D.vp
local b = m and DayNight.body()
if not b then return nil end
local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
if ww <= 1e-6 then return nil end
local amt, color = DayNight.glow()
return {
x = (x / ww * 0.5 + 0.5) * w,
y = (y / ww * 0.5 + 0.5) * h,
moon = b.moon,
glowAmt = amt,
glowColor = color,
}
end
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -426,8 +558,23 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(love.graphics.setCanvas)
return false
end
-- Ahead of the clear, because the sky's bands are placed off the ground
-- plane's vanishing line and that is a property of this matrix.
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
if 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
-- both set below. Sky.paint puts them aside anyway -- beginScene is not the
-- only thing that has ever left a shader bound.
--
-- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
-- pixel is on screen: the sky's dither grid is cut to that, so its squares
-- are the same size as the world's own and follow every resize and zoom.
-- The banded sky also hangs the hour's sun or moon (skyBody projects it
-- through this very camera); a flat sky has no bands and hangs nothing.
Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w),
sky.bands and Voxel3D.skyBody(w, h) or nil)
else
love.graphics.clear(0, 0, 0, 0, true, true)
end
@@ -438,7 +585,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
love.graphics.setMeshCullMode("none")
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
pcall(sh.send, sh, "eye", Voxel3D.eye)
-- the sun's frame, filled by ShadowMap just before this pass opened.
@@ -462,6 +608,22 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- start out flattening everything it drew.
pcall(sh.send, sh, "ghost", 0)
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 })
-- 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
local mask = Voxel3D.glassMask or GlassMask.blank()
if mask then
pcall(sh.send, sh, "glassMask", mask)
local ok, mw, mh = pcall(mask.getDimensions, mask)
pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 })
end
pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0)
pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0)
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)
-- 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).
@@ -558,6 +720,44 @@ function Voxel3D.flatten(color, amount)
end
end
-- Whether what is drawn next carries the voxel wireframe. false for the
-- length of a draw, true to put it back.
--
-- The wireframe reads a mesh's OWN model space and darkens its integer
-- planes (see VoxelGrid), which is only a wireframe because every mesh in
-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a
-- character card in the sprite's own pixels. A mesh whose model space does
-- not mean that gets no wireframe out of the same shader -- it gets
-- whichever of its integer planes happen to fall inside it, which is a
-- stray line rather than a seam.
--
-- So this is not a style switch. It is how a mesh that is not on the voxel
-- grid says so, and the alternative -- rescaling such a mesh until its
-- units happen to be voxels -- would change what it IS to satisfy a
-- shading pass.
--
-- Sent rather than branched because the plain scene shader has no such
-- uniform, and the send simply does not take there -- which is right: with
-- no wireframe compiled in there is nothing to suppress.
function Voxel3D.seams(on)
if not (active and activeShader) then return end
pcall(activeShader.send, activeShader, "gridDark",
on and VoxelGrid.DARK or 0)
end
-- Whether what is drawn next may consult the glass mask. false for the
-- length of a sprite-sheet pass, true to put it back.
--
-- Same shape as seams(), for the same reason: the mask means "this ATLAS
-- texel is window glass", so it is only an answer for meshes textured from
-- the tileset atlas. A sprite sheet's coordinates land wherever they land
-- on it, and at night that painted lamplight stripes down whoever was
-- standing in the wrong part of their own sheet.
function Voxel3D.glass(on)
if not (active and activeShader) then return end
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
@@ -741,6 +941,10 @@ function Voxel3D.invalidate()
end
canvas, canvasW, canvasH = nil, 0, 0
ShadowMap.invalidate()
-- the sky is part of this pass and holds a shader of its own
Sky.invalidate()
-- and the glass masks are textures of this context too
GlassMask.invalidate()
end
return Voxel3D
+216 -31
View File
@@ -1,4 +1,4 @@
-- Voxel world mode: assemble and draw one frame of the 3D scene.
-- Voxel world mode: assemble and draw one frame of the 3D scene.
--
-- World space is world pixels and shares its origin with the 2D paths, so
-- the terrain mesh needs no transform at all and a connected map just
@@ -20,6 +20,8 @@ local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -49,12 +51,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- At the top rung the camera is pitched far enough over that the horizon
-- comes into frame and a good part of the picture is void -- so the void
-- becomes the sky, and the diorama reads as standing under something
-- rather than floating on a black plate. Below that rung the camera looks
-- down steeply enough that the horizon is off-screen, and painting the
-- void only tints the gaps between meshes, so it stays transparent.
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
@@ -67,42 +73,78 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
-- mismatch the terrain bake had.
--
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
-- same way by the same seam; they are separate because the flat one also has to
-- serve an indoor void and a battle's arena, which want a colour rather than a
-- sky.
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
{ 64, 120, 192 }, { 16, 40, 80 } }
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
-- fade across the approach to the top rung, so the sky arrives with the
-- camera tween instead of popping in on the keypress
-- the ramp as the display mode has it, which is the only form anything here
-- should be reading it in
local function skyRamp()
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
end
-- Full strength at every rung: the sky is painted wherever the diorama is.
--
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
-- camera up from flat, and the sky comes up with it over the first few degrees
-- rather than appearing whole on the keypress -- which is also what keeps a
-- top-down camera, where there is no void worth speaking of, from painting one.
local SKY_FADE_DEG = 8
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below
local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung
if to <= from then return deg >= to and 1 or 0 end
local t = (deg - from) / (to - from)
if t < 0 then return 0 end
if t > 1 then return 1 end
return t
if deg <= 0 then return 0 end
local t = deg / SKY_FADE_DEG
return t < 1 and t or 1
end
-- One shade off the sky ramp, transformed by the display mode, as an
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
-- proper; 4 is its darkest, which is what an indoor void wants).
function VoxelScene.skyShade(shade, alpha)
local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
local shades = skyRamp()
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
end
-- The sky `map` stands under at strength `t`, or nil where there is no sky
-- to paint: indoors, or with the horizon out of frame.
--
-- One flat colour, which is what a caller that only needs something to clear the
-- void to wants -- the overworld battle's arena shot is one of those. The
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
return VoxelScene.skyShade(SKY_SHADE, t)
local sky = VoxelScene.skyShade(SKY_SHADE, t)
-- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
-- gold at dusk, navy at night -- so a battle staged on the map at
-- midnight is under a midnight void, not a noon one. Free-roam is
-- unchanged by this: Sky.dress overwrites the fill with the same value.
local haze = Sky.haze()
if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
return sky
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
-- (lib/Sky.lua).
--
-- Only here, and deliberately. This is the sky the walking camera stands under,
-- where the horizon is a quarter of the way down the frame at the top rung and
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
-- placed camera whose horizon sits above the frame entirely, so it keeps the
-- flat fill it has always had -- there is no gradient to see from down there,
-- and the arena's look is not this rung's to change.
local function skyFor(map)
return VoxelScene.skyColor(map, skyStrength(Voxel.angle))
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
if not sky then return nil end
return Sky.dress(sky)
end
VoxelScene._skyFor = skyFor -- named for the suite
@@ -205,6 +247,36 @@ local function billboardPull()
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
end
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
-- local space with its feet on y = 0, and this stands it at its drawn
-- position and tips it back by exactly the camera's pitch -- the same
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
-- around him. No cell centring: unlike a character he is not standing on a
-- cell, he is standing where he was drawn, which may straddle two.
local function figureMatrix(f, offX, offZ)
local Voxel = V.require("VoxelState")
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.rotateX(Voxel.angle - math.pi / 2))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
-- Voxel3D.casterMatrix does it for a character.
local function figureCaster(f, offX, offZ)
return Mat4.mul(
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.scale(1, 1, 0))
end
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
local function eachFigure(map, offX, offZ, draw)
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
end
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
@@ -235,12 +307,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED is what the sun saw (castShadows draws
-- exactly this mesh), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
-- vertex asks whether the light reached it; see ShadowMap.snug for why
-- the lookup must match the stored transform to the letter
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
return true
end
@@ -385,6 +458,38 @@ local function posesOf(state, spriteColors)
return posed, me
end
-- ------- the glint's drive
--
-- A reflection is something the VIEWPOINT does, so the window glint is fed
-- by the camera's own travel rather than by a clock: its phase advances
-- with distance covered and its strength fades in over a few steps of
-- walking and back out within a beat of standing still. Stand still and
-- the glass is still; move and the light crosses it.
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
-- texels (see the scene shader), so this is a FRACTION of a texel per world
-- pixel walked -- one full pass of the glint across a pane per eight or so
-- cells of travel, with no frame ever jumping it far enough to strobe.
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
function VoxelScene.glintStep(g, cx, cy)
local dist = 0
if g.x then
dist = math.abs(cx - g.x) + math.abs(cy - g.y)
end
g.x, g.y = cx, cy
g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi)
if dist > 0.05 then
g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN)
else
g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT)
end
return g
end
local glint = {}
-- A stamp of everything the sun pass depends on. Nothing in it moving
-- means the shadow map it produced last frame is still exactly right, and
-- redrawing the whole world from the sun would buy nothing -- which is
@@ -406,6 +511,12 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- standing perfectly still
put(vw); put(vh)
put(math.floor((V.require("VoxelState").angle or 0) * 512))
-- the sun itself: the cycle swings the shear as the clock runs, and a map
-- lit from somewhere new must be redrawn from there too. Quantised by the
-- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
-- few times a minute rather than every frame.
put(math.floor(ShadowMap.KX * 128))
put(math.floor(ShadowMap.KZ * 128))
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
@@ -442,11 +553,25 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
end
-- flower billboards live outside the terrain mesh (they draw after the
-- characters, pulled -- see render), but the sun still sees them: a
-- handful of cutouts per meadow, unlike the grass left out below
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
-- handful of cutouts per meadow, unlike the grass left out below.
-- Every thin card from here down is SNUGGED toward the sun along its own
-- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead
-- of starting a bias-width away.
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),
Mat4.translate(nb.ox, 0, nb.oy))
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
-- authored figures cast too, for the same reason the flowers do: a
-- handful of cards per map, and a person with no shadow reads as pasted on
eachFigure(state.map, 0, 0, function(mesh, _, caster)
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)
end
for _, p in ipairs(posed) do
local def = p.sprite.def
@@ -454,8 +579,9 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
local mesh = SpriteBillboards.shadowQuad(def, frame)
if mesh then
ShadowMap.draw(mesh, p.sprite:resolveImage(),
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
mirror))
ShadowMap.snug(
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
mirror)))
end
end
@@ -473,6 +599,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
local cam = state.camera
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
-- the hour's light, before anything is cast or drawn: point the shared
-- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
-- as dark as a cave at noon) and set the tint the scene shader multiplies
-- every surface by. A CANOPY map (Viridian Forest) is the case between:
-- the rig stays at noon and no sky is painted, but the hour's tint still
-- falls through the leaves -- night reaches a forest floor.
local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
DayNight.applyRig(outdoor)
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map))
-- and the window glass: the tileset's own panes (found in its art --
-- GlassMask), lit after dark. Outdoors only, like everything the clock
-- touches, which also keeps any pane-shaped art in an interior tileset
-- from picking up a glint.
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
local g = VoxelScene.glintStep(glint, cx, cy)
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
end
@@ -513,6 +658,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.endShadows()
end
-- Sprite sheets from here to the figure pass: their texture coordinates
-- mean nothing to the tileset-shaped glass mask, so the glass is off or
-- the panes' atlas positions stripe the cast with lamplight at night
Voxel3D.glass(false)
-- The player's silhouette goes down BEFORE the characters, so the only
-- thing it can meet in the depth buffer is the WORLD -- terrain, buildings,
-- trees. Drawn after the solid pass it would meet the player's own card
@@ -526,14 +676,46 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.endGhost()
end
-- characters, normally depth-tested: the camera-ward pull inside
-- Characters carry no wireframe out here, whatever the V-GRID row says.
-- The seams are what makes the WORLD read as built out of voxels, and
-- the people walking around in it are the one thing that should read as
-- drawn instead -- a grid over a 16x16 sprite lands a line every couple
-- of display pixels and turns a face into a mesh. (The battle pass makes
-- the opposite call for its own combatants, deliberately: that is a
-- staged shot rather than the world being walked around in -- see
-- BattleBillboard.)
--
-- Characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
Voxel3D.seams(false)
for _, p in ipairs(posed) do
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
-- back on for everything textured from the atlas again -- figures, grass
-- and flowers all sample it, where the mask's coordinates are honest
Voxel3D.glass(true)
-- Authored figures, alongside the characters and with the same lean and
-- the same camera-ward pull -- they ARE characters as far as the artwork
-- is concerned, just ones the tileset draws instead of a sprite sheet.
-- Drawn after the walkers so a player standing in front of the couch
-- wins the overlap, which is the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull,
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)
end
-- and the seams are back on for the terrain art that follows: grass and
-- flowers are the world's own drawing, not people
Voxel3D.seams(true)
-- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet
@@ -558,11 +740,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- ON, while the nearest flower of the cell south (+20) stays in front
-- and keeps overdrawing their feet.
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
-- flowers are snugged casters too, so they read their own shadowing
-- through the same snugged transform the sun stored them with
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull)
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)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
return Voxel3D.endScene()
+250 -22
View File
@@ -78,6 +78,9 @@ local ChunkMesher = V.require("ChunkMesher")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -157,6 +160,11 @@ mod.content.render_pipelines:register("voxel", {
-- would fight anyone who changed one deliberately.
applyFull(level)
Voxel.update(dt, level)
-- the day/night clock, on the same always-running tick: Pipelines.update
-- runs whatever the level, so time passes with the mode off, through
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
-- would mid-walk
DayNight.update(dt)
-- 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.
@@ -278,19 +286,71 @@ applyFull = function(level)
opts.zoom = 0
Zoom.applyOptions(opts)
-- battles on the map too: FULL means the whole mode, and a fight is where
-- half of it is spent. Set rather than forced -- the row is gone from the
-- menu while FULL is on, but a save that already had it off gets it on.
-- half of it is spent. Set and then LET GO of -- unlike the rows above, both
-- battle rows stay on the menu under FULL (see the rows hook), so this is
-- where the preset puts them and not where they are held.
OverworldBattle.setting:setIndex(1, Game)
-- with both mons out there on it: BACK SPRITES keeps the player's own on the
-- menu, which is the one part of the old screen FULL is least about. Set the
-- same way, and changed back on the same row a keypress later.
OverworldBattle.backSetting:setIndex(1, Game)
-- and the battle screen the staged fight is composed for. WIDE re-lays that
-- screen out on a 304x144 surface, which moves every anchor the arena camera
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
-- fights on, so the layout follows them.
OverworldBattle.forceOG(Game)
-- and the sky on the clock on the wall: FULL pins DAYTIME to SYNC. Unlike
-- the rest of the preset this one IS held, not just set -- the row is off
-- the menu while FULL owns it (the rows hook below), so a value changed
-- under it could never be seen or changed back.
DayNight.forceSync(Game)
if Game.writeOptions then pcall(Game.writeOptions, Game) end
end
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
-- concerned: the 3D-BTL row, and nothing else.
--
-- It used to answer yes under FULL as well, on the grounds that FULL owned
-- that row and switched it on. FULL no longer owns it -- the row stays on the
-- menu under FULL and can be switched off there (see the rows hook) -- so that
-- clause would now claim staged battles for a preset the player had just
-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
-- never staged. The row is the only thing that decides, which is what every
-- other reader of this setting already believed: OverworldBattle.begin and
-- wantsFront both gate on enabled() alone.
--
-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
-- this mode's rows say ON on a machine without a depth buffer too, and a menu
-- that claims 3D battles are on must not also offer the layout they cannot be
-- drawn in.
local function stagedBattles()
return OverworldBattle.enabled()
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
-- `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.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift." },
.. "shot over the shoulder with a slow parallax drift.",
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.",
when = function() return stagedBattles() 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." },
}
local schema = {}
@@ -320,9 +380,12 @@ mod.options:define(schema)
-- AND the engine's TILT on the same press.
--
-- Consequences worth being explicit about: while this mod is enabled, TILT
-- (3) and GBC FX (5) are unreachable by key. Both are still reachable on
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
-- registry already forces it off whenever a world pipeline takes the pass.
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
-- menu too, where both rows are taken away and both values held at zero (see
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
-- flat fake of what this mode does for real, the registry already forces it
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
-- present pass over the top of the diorama. Uninstalling puts both back.
--
-- Everything the engine does around a pipeline hotkey has to happen here
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
@@ -393,6 +456,11 @@ do
-- when the fight starts, so flipping it from inside one would be a
-- switch that appeared to do nothing.
claim:cycle(self)
-- 8 is one of the two ways staged battles get switched on, and they
-- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys
-- parameterise the pass and leave the layout alone; the guard answers
-- for all three, so nothing here has to know which key it was.
if stagedBattles() then OverworldBattle.forceOG(self) end
return
end
end
@@ -425,10 +493,12 @@ local function insertGrouped(out, extra)
return out
end
-- FULL owns every one of those settings, so while it is selected they are
-- taken off the menu rather than left to be changed under it -- including
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
-- longer decides anything is worse than no row.
-- FULL owns the settings that describe the LOOK, so while it is selected those
-- are taken off the menu rather than left to be changed under it -- including
-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
-- decides anything is worse than no row.
--
-- The battle rows are the exception and they stay; see the rows hook.
local function dropRow(out, id)
for i = #out, 1, -1 do
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
@@ -436,17 +506,87 @@ local function dropRow(out, id)
return out
end
-- ------- TILT and GBC FX are gone while this mod is installed
--
-- Both fight the diorama, and both were already half-taken: the mode's own key
-- (3) forces them off on every press, and the registry switches TILT off
-- whenever a world pipeline takes the pass. What was left was two rows the
-- player could set and watch get reverted -- TILT is the flat fake of what
-- this mode does for real, and GBC FX is a full-screen present pass over the
-- top of the whole thing.
--
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
-- Hiding a live setting is a trap: a save written before the mod was installed
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
-- back off. Pinned wherever the value could have arrived from -- the menu
-- opening, a save being loaded or begun -- so there is no route by which one
-- of them is on and unreachable.
--
-- Everything they did is still reachable: uninstall the mod and both rows are
-- back, at whatever they were last set to.
local function pinEngineFx(game)
game = game or require("src.core.Game")
local opts = game and game.save and game.save.options
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local changed = false
if opts then
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
opts.tilt, opts.gbcfx = 0, 0
end
pcall(Tilt.setLevel, 0)
pcall(GBCFX.setLevel, 0)
if changed and game.writeOptions then pcall(game.writeOptions, game) end
end
-- 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)
local out = next(game, rows)
if type(out) ~= "table" then return out end
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then
return dropRow(out, "pipeline:tiltshift")
-- ahead of every branch below, including FULL's early return: these two are
-- off the menu whatever else this mod is or is not doing
pinEngineFx(game)
dropRow(out, "tilt")
dropRow(out, "gbcfx")
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
-- one away. While a fight can be staged on the map, OG is the only layout it
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
-- and the row comes off the list on the same reasoning as the rows FULL owns:
-- a row that no longer decides anything is worse than no row. Nothing is
-- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
-- keypress.
if stagedBattles() then
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
local full = Voxel.isFull(Pipelines.level("voxel"))
if full 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.
DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
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
end
return insertGrouped(out, extra)
end)
@@ -457,6 +597,15 @@ mod.events:on("mod.options_changed", function(payload)
for _, entry in ipairs(SETTINGS) do
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
end)
-- ------- keeping the geometry in step with the world
@@ -539,7 +688,7 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
-- ------- FULL takes rows off the menu, so the menu has to notice
-- ------- rows come and go, so the menu has to notice
--
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
-- that list every frame. So stepping the VOXEL row onto or off FULL changed
@@ -547,25 +696,40 @@ end)
-- settings FULL owns stayed visible until the menu was closed and reopened,
-- and a player who stepped off FULL could not see the rows come back.
--
-- Rebuilt in place, and only on a step that crosses FULL: every other rung
-- returns the same list, and rebuilding on all of them would rerun every
-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
-- than reset, so it stays on the VOXEL row it was just used on instead of
-- jumping to the top when the list below it shortens.
-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL,
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
-- Every other rung returns the same list, and rebuilding on all of them would
-- 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.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
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
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
local wasOn = idAt(self, self.index)
inner(self, dt)
local after = Pipelines.level("voxel")
if after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after)) then
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles 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:
-- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
-- otherwise slide the cursor onto the row under the one just used.
for i = 1, #self.rows do
if wasOn and idAt(self, i) == wasOn then self.index = i; break end
end
local cancel = #self.rows + 1
if (self.index or 1) > cancel then self.index = cancel end
end
@@ -620,7 +784,71 @@ mod.events:on("battle.ended", function()
OverworldBattle.finish()
end)
mod.exports.version = "1.1.1"
-- ------- and the way back out
--
-- The engine wipes INTO a battle with one of the original's eight transitions
-- and cuts straight OUT of it. That cut is between two very different cameras
-- in this mode, so while voxel mode is on the battle fades out, closes behind
-- the black, and the map fades up. The two seams it needs -- BattleState:finish
-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua.
--
-- Declared as a transitions record rather than a constant in that file, so the
-- fade is retunable in data exactly like the eight wipes it answers, and a total
-- conversion can make it as long or as short as its own pacing wants.
mod.content.transitions:register(BattleExit.ID, {
frames = BattleExit.FRAMES,
})
BattleExit.install()
-- ------- and the hour on the flat world
--
-- The clock reaches the diorama through the voxel shader's own tint uniform,
-- which the 2D tile path never runs -- so with the mode off, the same evening
-- that fell on the diorama left the flat world at permanent noon. One clock,
-- two worlds, one of them ignoring it. DayTint paints the same multiply over
-- the composited flat world, between the world blit and the UI blit; the
-- reasoning for that exact instant is in the file.
DayTint.install()
-- ------- what time it is
--
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
-- time it is in Kanto is a fact about that journey, like where the player is
-- standing. Written on the engine's save.writing event -- the moment before
-- the bytes hit disk -- and read back whenever a save is opened or begun. A
-- save with no clock in it starts at day; that is DayNight.restore's
-- fallback, and also the DAYTIME row's own default.
mod.events:on("save.writing", function()
DayNight.store()
end)
mod.events:on("save.loaded", function()
DayNight.restore()
-- a save written before this mod was installed can carry TILT or GBC FX
-- switched on, and their rows are not there to switch them back off (see
-- pinEngineFx). Answered here rather than only when the menu opens, so a
-- player who never opens it is not left playing under one.
pinEngineFx()
end)
mod.events:on("save.created", function()
DayNight.restore()
pinEngineFx()
end)
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
-- eternal "DAY" until a mod answers here; answering it hands the period to
-- the map.palette hook (ctx.tod) and music.select, so a palette or music
-- pack keyed to night works with this mod's clock for free. next() first: a
-- mod loaded before this one that already moved the time keeps its answer.
mod.hooks:wrap("world.tod", function(next, tod, ctx)
local out = next(tod, ctx)
if out ~= tod then return out end
return DayNight.tod()
end)
mod.exports.version = "1.3.0"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+2 -2
View File
@@ -1,12 +1,12 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.1.1",
"version": "1.3.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": "0.0.0-dev || >=0.1.28 <2.0.0",
"game_version": "0.0.0-dev || >=0.1.37 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+6 -4
View File
@@ -11,17 +11,19 @@ return {
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
},
added = {
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"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",
"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",
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
@@ -38,5 +40,5 @@ return {
credits = {
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
},
compat = { engine = ">=0.1.28 <2.0.0", modApi = 2 },
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
}
+109
View File
@@ -0,0 +1,109 @@
-- Driver: screenshot the day/night cycle.
--
-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
-- mid-blend moments of the running cycle, a battle staged under the night
-- sky, and a room at midnight -- which must look exactly like a room at
-- noon, because indoors the clock does not reach.
--
-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
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 DayNight = lib.require("DayNight")
local function setTime(value, clock)
DayNight.setting:sync(value)
if clock then DayNight.clock = clock end
DayNight.update(0)
end
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
-- straight to the top rung, whatever the persisted options say: pressing
-- the key would walk the ladder RELATIVE to wherever a previous run left it
local Pipelines = require("src.render.Pipelines")
Pipelines.setLevel("voxel", 5)
U.wait(150) -- let the camera ease over and the meshes land
-- ------- the four pins, one vantage
for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
setTime(phase)
U.wait(30) -- a couple of rig steps + palette settle
U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
end
-- ------- the running cycle, mid-blend
setTime("cycle", 560) -- day leaning into dusk
U.wait(10)
U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
setTime("cycle", 645) -- dusk falling into night
U.wait(10)
U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
-- ------- golden hour on open ground, long shadows
U.teleport(game, "ROUTE_1", 8, 12, "up")
U.wait(120)
setTime("cycle", 555) -- low western sun, shadows long and eastward
U.wait(30)
U.shot(game, DIR .. "/30_route1_low_sun.png")
-- ------- a fight staged under the night sky
setTime("night")
game.save.party = game.save.party or {}
if #game.save.party == 0 then
game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
end
-- any real trainer class: the dataset's ids vary by merge, so take the
-- first one in stable order rather than guessing a name
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
if battle then
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
U.shot(game, DIR .. "/40_night_battle.png")
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
-- ------- and a room, where midnight must change nothing
setTime("night")
if game.data.maps.REDS_HOUSE_1F then
U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
U.wait(90)
U.shot(game, DIR .. "/50_indoor_at_night.png")
end
-- ------- the forest, where only the TINT of it reaches: night falls
-- through the canopy, but there is no sky and the noon light stays put
setTime("night")
if game.data.maps.VIRIDIAN_FOREST then
U.teleport(game, "VIRIDIAN_FOREST", 17, 20, "up")
U.wait(120)
U.shot(game, DIR .. "/51_forest_night.png")
setTime("day")
U.wait(30)
U.shot(game, DIR .. "/52_forest_day.png")
end
setTime("day")
U.log("done -- " .. DIR)
end
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
-- Probe: one overworld battle, held on the menu beat, shot large.
--
-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love .
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")
love.math.setRandomSeed(20260727)
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
game.save.player.name = "RED"
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
U.log("DRAMATIC_SHAPE lib:", tostring(lib))
local Battles = lib and lib.require("OverworldBattle")
U.log("OverworldBattle:", tostring(Battles))
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(120)
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
U.log("class:", classes[1])
local battle = BattleState.newTrainer(game, classes[1], 1)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local arena = Battles and Battles.arena()
U.log("arena:", arena and arena.shape or "none")
U.shot(game, DIR .. "/probe_menu.png")
for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end
U.wait(90)
U.shot(game, DIR .. "/probe_menu2.png")
U.log("done -- " .. DIR)
love.event.quit()
end
+218
View File
@@ -0,0 +1,218 @@
-- Driver: propose and photograph several battle arenas for ONE map.
--
-- data/battle_arenas.lua holds a single authored spot per area, chosen by
-- arena_pick's nearest-to-the-middle search and then looked at. This is the
-- other half of that job: when the shipped spot is up for review, it lays out
-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread
-- along the map so the shortlist is places rather than neighbours -- and
-- stages a real battle in each so they can be compared by eye.
--
-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love .
--
-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph.
-- One `CAND` line per shot, ready to paste into the data file, plus a PNG
-- named for its corner and shape.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates"
local MAP = os.getenv("CAND_MAP") or "ROUTE_1"
local WANT = tonumber(os.getenv("CAND_N") or "") or 5
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
game.save.player.name = "RED"
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 Arena = lib.require("BattleArena")
local Battles = lib.require("OverworldBattle")
U.teleport(game, MAP, 1, 1, "down")
local map = game.overworld.map
U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells))
-- ------- every arena the map can offer
--
-- BattleArena.search answers "the nearest one", which is the wrong question
-- for a shortlist -- it returns one spot and hides the rest. So walk the
-- same grid ourselves and keep them all, tagged with whether the pair would
-- actually be SEEN there (Arena.clearance), because an obstructed spot is
-- not a candidate no matter how good the ground looks.
--
-- The map's outermost cells are its CONNECTION BORDER -- the strip the
-- neighbouring map is drawn into, walkable so the player can step across.
-- Ground there passes every test and is still the wrong answer: a fight
-- staged on it happens at the edge of the world with the border ring's tree
-- wall at the mons' backs, and every spot in the strip looks like every
-- other one. CAND_MARGIN keeps the shortlist on the route proper.
local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2
local cands = {}
for _, shape in ipairs(Arena.SHAPES) do
for y = MARGIN, map.heightCells - shape.h - MARGIN do
for x = MARGIN, map.widthCells - shape.w - MARGIN do
local fits = true
for cy = y, y + shape.h - 1 do
for cx = x, x + shape.w - 1 do
if not Arena.openCell(map, cx, cy, false) then fits = false break end
end
if not fits then break end
end
if fits then
local a = Arena.at(x, y, shape.id)
if a and Arena.clearance(map, a) then
cands[#cands + 1] = { x = x, y = y, shape = shape.id,
mx = x + (shape.w - 1) / 2,
my = y + (shape.h - 1) / 2 }
end
end
end
end
end
U.log(("%d clear arenas on %s"):format(#cands, MAP))
if #cands == 0 then U.log("done -- nothing to propose") return end
for _, c in ipairs(cands) do
U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape))
end
-- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead
-- of the spread one. The spread is a first pass over ground the clearance
-- test approved, and that test measures terrain height along the sightline
-- only -- it has no opinion on a hedge sitting in the apron row between the
-- camera and the near mon, which is the failure that keeps turning up. So
-- the loop is: spread, look, then re-shoot the survivors and the
-- replacements by hand.
local explicit = os.getenv("CAND_AT")
if explicit and explicit ~= "" then
local list = {}
for spot in explicit:gmatch("[^;]+") do
local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$")
if x then
list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s }
else
U.log("BAD CAND_AT entry: " .. spot)
end
end
cands = list
WANT = #list
U.log(("%d spots given explicitly"):format(#list))
end
local function shapeOf(id)
for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end
end
for _, c in ipairs(cands) do
local s = shapeOf(c.shape)
c.mx = c.x + ((s and s.w or 1) - 1) / 2
c.my = c.y + ((s and s.h or 1) - 1) / 2
end
-- ------- thin them down to a shortlist that is actually a CHOICE
--
-- Adjacent corners are the same patch of ground shifted a cell, so a naive
-- top-N is five photographs of one place, and pure farthest-point selection
-- goes straight to the extremes -- which on a route means the ends, where
-- the ground is emptiest and the shots are least distinguishable.
--
-- So: spread along the route's LONG AXIS, one pick per band, and within a
-- band take the spot nearest the middle of the road. The road is measured
-- rather than assumed -- the median cross-axis position of everywhere a
-- fight fits IS the lane, on a map whose walkable ground is mostly lane.
local horizontal = map.widthCells > map.heightCells
local function along(c) return horizontal and c.mx or c.my end
local function across(c) return horizontal and c.my or c.mx end
local xs = {}
for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end
table.sort(xs)
local road = xs[math.ceil(#xs / 2)]
U.log(("road runs %s, centre of the lane is %s = %.1f")
:format(horizontal and "east-west" or "north-south",
horizontal and "y" or "x", road))
local lo, hi = math.huge, -math.huge
for _, c in ipairs(cands) do
lo, hi = math.min(lo, along(c)), math.max(hi, along(c))
end
-- an explicit shortlist is already the answer; the spread below would only
-- thin it, and its overlap rule would silently drop two spots deliberately
-- asked for a cell apart
local picked, taken = {}, {}
for band = 1, (explicit and explicit ~= "") and 0 or WANT do
-- band centres, not band edges: the first and last picks sit inside the
-- route rather than on its two connection mouths
local target = lo + (hi - lo) * (band - 0.5) / WANT
local best, bestScore
for _, c in ipairs(cands) do
if not taken[c] then
local da = along(c) - target
local dr = across(c) - road
-- distance from the band centre, plus a heavier penalty for being off
-- the lane; wide arenas are worth a detour, being the shot this mode
-- is framed for
local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0)
if not bestScore or score < bestScore then best, bestScore = c, score end
end
end
if best then
picked[#picked + 1] = best
-- everything overlapping the pick is off the table, so two bands whose
-- best spots touch cannot return the same patch of ground twice
for _, c in ipairs(cands) do
if math.abs(along(c) - along(best)) < 3
and math.abs(across(c) - across(best)) < 3 then
taken[c] = true
end
end
end
end
if #picked == 0 then picked = cands end
-- north to south (or west to east), so the filenames read along the route
table.sort(picked, function(a, b)
if along(a) ~= along(b) then return along(a) < along(b) end
return across(a) < across(b)
end)
for i, c in ipairs(picked) do
U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },")
:format(i, MAP, c.x, c.y, c.shape))
-- forced through the authored-entry seam, so what gets staged is exactly
-- this spot rather than whatever the search would pick from the player's
-- cell
Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape })
local staged = Arena.find(map, 0, 0, false)
if not staged then
U.log(("SKIP %d -- override did not stage"):format(i))
else
game.overworld.player.cellX = staged.playerCell[1]
game.overworld.player.cellY = staged.playerCell[2]
U.wait(90) -- let the meshes land
local battle = BattleState.newWild(game, "NIDORINO", 20)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local got = Battles.arena()
U.log(("SHOT %d staged at %s,%s"):format(i, tostring(got and got.x),
tostring(got and got.y)))
U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png")
:format(DIR, i, MAP:lower(), c.x, c.y, c.shape))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(6)
end
end
Arena.setOverride(MAP, nil)
U.log("done -- " .. DIR)
end
+55
View File
@@ -0,0 +1,55 @@
-- Driver: prove the banded sky still paints from the ramp texture.
--
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
-- Android delivered only its first few slots and painted the rest of the sky
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
-- three things that swap could have broken, on the machine it CAN be checked
-- on: that the shader still compiles, that the ramp is built and is one texel
-- per band, and that what lands on screen is still a gradient that pales
-- downward rather than a flat plate or a black one.
--
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
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 Sky = lib.require("Sky")
local DayNight = lib.require("DayNight")
-- which copy of the mod is actually live: only the ramp build has this
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
require("src.render.Pipelines").setLevel("voxel", 5)
U.wait(150)
for _, phase in ipairs({ "day", "dusk", "night" }) do
DayNight.setting:sync(phase)
DayNight.update(0)
U.wait(30)
local bands = Sky.bands()
local ramp = Sky._rampFor and Sky._rampFor(bands)
local w = ramp and ramp:getWidth() or -1
U.log(("%s: %d bands, ramp %dx%d"):format(
phase, #bands, w, ramp and ramp:getHeight() or -1))
-- one texel per band is the whole contract: the shader divides by `count`
-- and samples texel centres, so a ramp of any other width samples between
-- bands or off the end
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
-- and the ramp is the ramp: the same table gives the same image back
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
U.shot(game, ("%s/%s.png"):format(DIR, phase))
end
DayNight.setting:sync("day")
U.log("done -- " .. DIR)
end
+175
View File
@@ -0,0 +1,175 @@
-- Driver: isolate the diagonal banding on flat lit geometry.
--
-- Shoots the SAME stand point four ways in ONE launch, so palette, camera
-- and framing are identical and the only variable is the sun pass:
--
-- _on shadows as shipped
-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it
-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off
--
-- and prints, from the frustum the run actually fitted, how much depth
-- slack each face orientation NEEDS against the slack it is given. A face
-- whose need exceeds the slack shadows itself in a moire -- acne -- which
-- reads as diagonal banding, since the ramp it aliases against runs along
-- the light's frame and the sun sits southeast.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec .
--
-- knobs (env):
-- ACNE_MAP map id (default VIRIDIAN_CITY)
-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall)
-- ACNE_LEVELS comma list of voxel levels (default "3")
-- ACNE_FLAT comma list of constant-bias values to compare against
-- (default "1" -- what shipped before SLOPE existed)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY"
local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "up"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing")
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local Mat4 = V.require("Mat4")
local levels = {}
for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do
levels[#levels + 1] = tonumber(n)
end
local flats = {}
for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do
flats[#flats + 1] = tonumber(n)
end
-- ---- what the frustum this run fitted asks of the bias, per face
local FACES = {
{ "top +Y", 0, 1, 0 },
{ "south +Z", 0, 0, 1 },
{ "east +X", 1, 0, 0 },
{ "west -X", -1, 0, 0 },
{ "north -Z", 0, 0, -1 },
{ "roof 45 N", 0, 0.7071, -0.7071 },
{ "roof 45 S", 0, 0.7071, 0.7071 },
}
local function report(tag)
local e = ShadowMap.extent
if not e then print("[acne] " .. tag .. ": no frustum yet") return end
local res = ShadowMap.res
local texX, texY, depth = e[1] / res, e[2] / res, e[3]
local d = ShadowMap.sunDir()
local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 })
local R = { view[1], view[2], view[3] }
local Up = { view[5], view[6], view[7] }
local F = { -view[9], -view[10], -view[11] }
print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f"
.. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)")
:format(tag, res, e[1], e[2], depth, texX, texY,
ShadowMap.slack, ShadowMap.bias))
for _, fc in ipairs(FACES) do
local n = { fc[2], fc[3], fc[4] }
local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3]
local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3]
local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3]
-- a face the sun cannot see never samples its own depth, so it
-- cannot alias -- FACE_SHADE darkens those and the map is moot
if nf < 0 then
-- the plane's depth gradient in light space, times the half-texel
-- the 2x2 filter reaches out on each axis
local need = 0.5 * (math.abs(nr / nf) * texX
+ math.abs(nu / nf) * texY)
print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s")
:format(fc[1], math.abs(nf),
math.sqrt((nr/nf)^2 + (nu/nf)^2), need,
need > ShadowMap.slack and "<-- ACNE" or "ok"))
else
print(("[acne] %s unlit (sun behind it)"):format(fc[1]))
end
end
end
local Zoom = require("src.render.Zoom")
Zoom.reset()
local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0)
for _ = 1, math.abs(steps) do
Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
end
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
do
local vw, vh = game.renderer:worldViewSize()
print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d")
:format(mapId, sx, sy, facing, vw, vh, Zoom.offset))
end
local shipped = Voxel3D.SHADOW_ALPHA
local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE
-- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid
-- on/off), so a band can be attributed to one of them rather than
-- guessed at. The grid is the other thing in this pass that draws
-- regular lines, and at a grazing angle it moires.
local VoxelGrid = V.require("VoxelGrid")
local matrix = os.getenv("ACNE_MATRIX") == "1"
local function shoot(name)
game.capturePath = ("%s/acne_%s.png"):format(DIR, name)
wait(4)
end
for _, level in ipairs(levels) do
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the camera tween
local label = Pipelines.levelLabel("voxel", level) or level
print(("[acne] --- level %s shadowsActive=%s")
:format(label, tostring(Voxel3D.shadowsActive())))
report("as shipped")
shoot(("%s_v%s_on"):format(mapId, label))
if matrix then
for _, grid in ipairs({ true, false }) do
VoxelGrid.sync(grid)
for _, sun in ipairs({ true, false }) do
Voxel3D.SHADOW_ALPHA = sun and shipped or 0
wait(8)
shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label,
grid and "1" or "0", sun and "1" or "0"))
end
end
Voxel3D.SHADOW_ALPHA = shipped
end
-- the sun pass still runs; the main pass simply stops reading it
Voxel3D.SHADOW_ALPHA = 0
wait(6)
shoot(("%s_v%s_off"):format(mapId, label))
Voxel3D.SHADOW_ALPHA = shipped
for _, b in ipairs(flats) do
ShadowMap.BIAS, ShadowMap.SLOPE = b, 0
ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias
wait(8)
report(("flat %.1f"):format(b))
shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p")))
end
ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope
ShadowMap.invalidate()
wait(6)
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[acne] done")
end
+246
View File
@@ -0,0 +1,246 @@
-- Driver: the A/B performance benchmark.
--
-- One scripted, deterministic session that exercises the three things the
-- mod is slow at, and writes the numbers to a JSON file so two runs can be
-- diffed:
--
-- 1. LOADING IN -- engaging the mode from flat: shader compiles,
-- canvas allocations, the first map's mesh, its
-- atlas bakes and its glass mask, all at once.
-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold
-- caches, then walking the SAME route again with
-- them warm. The gap between the two is the
-- complaint; closing it is the fix.
-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree
-- rung puts the horizon in frame, which triples the
-- sun frustum and hands the sky its disc to draw.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \
-- DS_PERF=1 BENCH_TAG=baseline lovec.exe .
--
-- knobs (env):
-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing
-- BENCH_TAG output name, ds_bench/<tag>.json (default "run")
-- BENCH_HOLD frames to walk per leg (default 900)
--
-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is
-- measuring the weather:
--
-- * VSYNC OFF. With it on every frame costs exactly one refresh interval
-- and the whole exercise reads as 16.7ms flat, saving or no saving.
-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun
-- angle, the shadow frustum and whether windows are lit -- so an
-- unpinned run compares two different scenes.
-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges
-- its frames to whichever map the script thought it was on. Stubbed on
-- the state class for this process only; nothing is written to disk.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local TAG = os.getenv("BENCH_TAG") or "run"
-- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that
-- means to reach Viridian needs about 1200 -- short of that the "new
-- area" the benchmark is named for never gets entered.
local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300)
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure")
return
end
local V = handle.lib
local Perf = V.require("Perf")
if not Perf.enabled then
print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing")
return
end
local loadBytes = V.loadBytes
local Structures = V.require("Structures")
local ChunkMesher = V.require("ChunkMesher")
local Buildings = V.require("Buildings")
local DayNight = V.require("DayNight")
local VoxelScene = V.require("VoxelScene")
-- module internals worth naming that the mod does not time itself
Perf.wrap(Structures, "forMap", "Structures.forMap")
Perf.wrap(Buildings, "build", "Buildings.build")
Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump")
Perf.wrap(VoxelScene, "render", "VoxelScene.render")
if love.window and love.window.setVSync then
pcall(love.window.setVSync, 0)
end
DayNight.setting:sync("day")
OverworldState.rollEncounter = function() return nil end
-- ---- the run ------------------------------------------------------
local function seg(name)
Perf.setSegment(name)
-- the frame that STRADDLES a segment boundary belongs to neither: it
-- carries the teleport, the level change or the report print that
-- opened it, and charging that to the new segment libels it
Perf.resync()
end
local function settle(frames)
Perf.setSegment(nil)
U.wait(frames or 60)
Perf.resync()
end
-- Hold a direction, attributing each frame to the map the player is
-- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of
-- the walk, so the segment has to follow the player rather than the
-- script's idea of where they are.
local function walk(dir, frames, prefix)
local last = nil
for _ = 1, frames do
local o = game.overworld
local id = o and o.map and o.map.id
if id ~= last then
last = id
seg(prefix .. ":" .. tostring(id))
print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id),
U.frame()))
end
-- press and RELEASE each frame, the way tests/voxel_perf_probe's seam
-- crossing does: a direction left held accumulates in pressQueue and
-- the walk stalls where a single tap would have stepped
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
game.input.state[dir] = false
end
local o = game.overworld
print(("[bench] %s ended on %s at cell (%d,%d)"):format(
prefix, tostring(o and o.map and o.map.id),
o and o.player and o.player.cellX or -1,
o and o.player and o.player.cellY or -1))
Perf.setSegment(nil)
end
print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes))
-- ORDER MATTERS. The cold walk has to be the first time this session
-- draws Route 1 and Viridian, so everything before it stays in Pallet
-- Town -- a map whose caches the walk does not depend on. Measuring a
-- "first entry" into a map an earlier segment already warmed is the one
-- way to make this whole benchmark lie.
-- 1. the flat reference: the game with this mod present but not
-- drawing. Every later number is only interesting against this one.
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
Pipelines.setLevel("voxel", 0)
Pipelines.setLevel("tiltshift", 0)
settle(90)
seg("flat")
U.wait(180)
-- 2. loading in: FULL is what a player picks first and it is the most
-- expensive configuration there is (tilt-shift to maximum, 3D
-- battles on). Measured from the frame the level changes, so it
-- carries the shader compiles, the canvas allocations, the first
-- mesh, the first atlas bake and the first glass scan together.
settle(60)
seg("engage.full")
Pipelines.setLevel("voxel", 1) -- FULL
U.wait(240)
Perf.setSegment(nil)
-- 3. the low camera. 75 degrees puts the horizon in frame; the rungs
-- below it are the control. Standing still, so what is measured is
-- the frame's own cost and not the walk's mesh streaming.
for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees
Pipelines.setLevel("voxel", rung)
settle(60) -- let the tween finish
seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1]))
U.wait(180)
Perf.setSegment(nil)
end
-- 3b. the same low camera at DUSK, which is where the sky costs most:
-- the horizon is in frame, so the banded region is at its tallest,
-- and the sun is low enough to be in it -- the disc only draws at
-- all when it is above the horizon point, so a midday run never
-- touches that code and would report it as free.
for _, when in ipairs({ "dusk", "night" }) do
DayNight.setting:sync(when)
settle(60)
seg("pitch:75:" .. when)
U.wait(180)
Perf.setSegment(nil)
end
DayNight.setting:sync("day")
settle(30)
-- 4/5. arriving somewhere new, at the rung that hurts.
--
-- Arrival is measured by LOADING each map rather than by walking into
-- it. Walking would be more lifelike, but Route 1's ledges make a held
-- direction stall against geometry, so a fixed frame count buys a
-- different amount of travel on every run -- and a benchmark whose
-- route drifts cannot compare two runs at all. A load is the same
-- arrival stripped of the travel: the map swaps, and the next frames
-- pay for its mesh, its structure analysis, its atlas bake and its
-- glass mask exactly as they do behind a door fade.
--
-- Then the identical list a second time. Every cost in the gap between
-- the two passes is a cache that was cold, and that gap IS the
-- complaint.
Pipelines.setLevel("voxel", 5) -- 75 degrees
local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22",
"VIRIDIAN_FOREST", "PEWTER_CITY" }
local DWELL = math.max(60, math.floor(HOLD / #TOUR))
-- Stand in the middle of each map, derived from its own def rather than
-- written down: a hardcoded cell that falls outside a map teleports the
-- player nowhere and the segment silently measures the previous map.
local function centreOf(id)
local def = game.data.maps and game.data.maps[id]
if not def then return nil end
return math.floor(def.width), math.floor(def.height)
end
local function tour(prefix)
for _, id in ipairs(TOUR) do
local cx, cy = centreOf(id)
if cx then
U.teleport(game, id, cx, cy, "up")
-- the segment opens on the frame AFTER the teleport, so the load
-- itself is not charged to the arrival it caused
seg(prefix .. ":" .. id)
U.wait(DWELL)
Perf.setSegment(nil)
else
print("[bench] skipping unknown map " .. id)
end
end
end
tour("first")
tour("revisit")
-- 6. streaming while walking: the one crossing that is reliably
-- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1
-- south into Pallet, which pulls a neighbour's meshes in mid-stride.
U.teleport(game, "ROUTE_1", 10, 34, "down")
settle(120)
walk("down", 240, "walk")
-- ---- the report ---------------------------------------------------
Perf.setSegment(nil)
Perf.printReport("bench " .. TAG)
Perf.write(TAG, {
tag = TAG,
loadBytes = loadBytes,
hold = HOLD,
texturememory = Perf.texturememory or 0,
canvases = Perf.canvases or 0,
images = Perf.images or 0,
})
print("[bench] done")
end
+245
View File
@@ -0,0 +1,245 @@
-- Figures: a person drawn INTO furniture, cut out by an authored pixel
-- mask and stood up on top of it (TileShape.figures / Structures.
-- buildFigures). The Pokemon Center's seated man is the case the feature
-- exists for, so this drives the real profile entry over a hand-built
-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08,
-- as every Center places it:
--
-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN,
-- y=9 52 53 60 27 57/60 floor he overhangs east onto,
-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base
-- y=11 42 43 26 27
--
-- No love, no GPU and no pixel access: a figure is authored rather than
-- detected, which is exactly what lets it build headless.
--
-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader)
local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
local V = { path = ROOT }
local function chunkFor(rel)
local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing")
local src = f:read("*a")
f:close()
return assert(load(src, "@" .. ROOT .. "/" .. rel))
end
local modules, dataFiles = {}, {}
function V.require(name)
if modules[name] == nil then
modules[name] = chunkFor("lib/" .. name .. ".lua")(V)
end
return modules[name]
end
function V.data(name)
if dataFiles[name] == nil then
dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V)
end
return dataFiles[name]
end
local TileShape = V.require("TileShape")
local Structures = V.require("Structures")
-- ------- the authored mask parses
local figs = TileShape.figures("POKECENTER")
T.check(type(figs) == "table" and #figs == 1,
"POKECENTER carries exactly one figure")
local fig = figs[1]
T.eq(fig.w, 3, "the figure is three tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans")
T.check(fig.class == nil,
"a figure carries no class -- it is always a flat sprite card")
local W = fig.w * 8
local function on(lx, ly) return fig.mask[ly * W + lx] == true end
-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the
-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7
-- only, rows 3-7 are his hair in both
T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1),
"the arm's own top rows stay with the couch")
T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2")
for ly = 3, 7 do
T.check(on(6, ly) and on(7, ly),
"the back of his head fills both columns at row " .. ly)
end
-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND
-- the right side of his face, and masking it out by shade slit his cheek
for ly = 5, 8 do
T.check(on(15, ly),
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
end
T.eq(fig.tiles[1], 36, "it starts at the couch's west arm")
T.eq(fig.tiles[2], 37, "then the tile his head is drawn in")
T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off")
T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion")
T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)")
T.eq(fig.under[6], 26, "and (60 -> 26)")
-- the mask is one connected figure: an overhang that floats free of him
-- is the failure this feature exists to avoid
local seen, first = {}, nil
for i in pairs(fig.mask) do first = first or i end
local stack, reached = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
reached = reached + 1
local x, y = i % W, math.floor(i / W)
for dy = -1, 1 do
for dx = -1, 1 do
local nx, ny = x + dx, y + dy
local ni = ny * W + nx
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W
and fig.mask[ni] and not seen[ni] then
seen[ni] = true
stack[#stack + 1] = ni
end
end
end
end
T.eq(reached, fig.n, "the mask is a single connected figure")
-- ------- the couch block, as the Centers place it
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
local COUNTER = { class = "counter", h = 8, art = "upright",
flat = false, authored = true }
local GROUND = { class = "ground", h = 0, art = "flat",
flat = true, authored = false }
local BLOCK = { 36, 37, 57, 11,
52, 53, 60, 27,
38, 39, 54, 11,
42, 43, 26, 27 }
local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true,
[42] = true, [43] = true, [52] = true, [53] = true }
local function scene()
local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
ground = {}, runs = {} }
for i = 1, 16 do
local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4)
local tile = BLOCK[i]
S.tileAt[keyOf(tx, ty)] = tile
S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND
end
return S
end
-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn
-- sitting on it), the floor cell east of it is walkable
local map = {
tileset = { id = "POKECENTER", tilesPerRow = 16,
imageWidth = 128, imageHeight = 48 },
isWalkableCell = function(_, cx) return cx >= 1 end,
}
-- ------- he comes off the couch
local S = scene()
Structures.buildFigures(S, map, 0, 3, 8, 11)
T.eq(#S.figures, 1, "one figure card was built")
local card = S.figures[1]
T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else")
T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now")
T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion")
T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too")
T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again")
T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it")
T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched")
T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base")
-- the couch keeps its box: a figure changes ART, never class
T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
"his tiles are still the couch's half-cell box")
T.check(S.skip[keyOf(1, 8)] ~= true,
"and are not skipped -- the couch still renders there")
-- ------- the card is flat, and stands at its feet
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(card.quads) do
for c = 1, 4 do
local p = q[c]
minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1])
minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2])
minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3])
end
end
T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)")
T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab")
T.eq(minY, 0, "local space: his feet are the card's origin")
T.eq(maxY, 16, "and he is his drawn 16px tall")
T.eq(minX, 0, "his west edge is the card's origin too")
T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang")
-- ------- and where VoxelScene stands it
T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor")
T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6")
T.eq(card.wz, 76,
"and pivoting at the middle of the tile row his feet are drawn in")
-- ------- a map that does not draw him builds nothing
local other = scene()
other.tileAt[keyOf(1, 8)] = 40
Structures.buildFigures(other, map, 0, 3, 8, 11)
T.eq(#other.figures, 0, "no match, no figure")
T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted")
-- ------- and it never fires twice on the same drawing
local twice = scene()
Structures.buildFigures(twice, map, 0, 3, 8, 11)
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
-- the bottom of the plant block, so the ordinary vote (shades touching the
-- drawing's own bounding box) read "dark" as background and drained every
-- olive pixel in the plant.
local bgRules = TileShape.propBg("POKECENTER")
T.check(type(bgRules) == "table", "POKECENTER names prop background shades")
for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do
local set = bgRules and bgRules[tile]
T.check(type(set) == "table" and set.light and set.white
and not set.dark and not set.black,
"plant tile " .. tile .. ": light/white are background, dark is the pot")
end
-- and it is scoped: the healing consoles' screens and the PC keep the
-- ordinary vote, because they want the opposite call on those same shades
for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do
T.check(bgRules[tile] == nil,
"tile " .. tile .. " keeps the ordinary background vote")
end
-- a tileset with no prop_bg at all answers nil rather than an empty table,
-- so Structures can skip the lookup entirely
T.check(TileShape.propBg("CAVERN") == nil,
"a tileset that names none answers nil")
T.finish("DRAMATIC_SHAPE figures")
+346
View File
@@ -0,0 +1,346 @@
-- Driver: the pixel-identity gate for performance work.
--
-- Every optimization in this mod's performance pass claims the frame comes
-- out the same. This driver is what makes that claim checkable rather than
-- asserted: it renders a fixed set of scenes -- several maps, indoors and
-- out, at every camera rung, in every display mode -- and writes one PNG
-- per scene. Run it before a change and after it, hash the two directories,
-- and any file whose hash moved is a scene the change altered.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \
-- SHOT_DIR=<dir> AB_TAG=before lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory (created if missing) (default "shots/ab")
-- AB_TAG subdirectory under SHOT_DIR (default "before")
-- AB_MODES display modes to sweep, comma list (default all four)
--
-- DETERMINISM is the whole game here, because a shot that differs for a
-- reason other than the change under test makes the gate useless:
--
-- * the day/night clock is PINNED (an unpinned sky is a different sky
-- every second, and it drives the sun angle and the shadow frustum);
-- * the animated tile slots ride the engine's 60Hz counter, so every
-- scene is reached after the SAME number of frames from the same
-- starting state, and the water is at the same point in its roll;
-- * levels are set through Pipelines.setLevel, never the hotkey, so the
-- run cannot write the player's options;
-- * encounters are stubbed off -- a wild battle would replace the scene
-- the shot is named for.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local ROOT = (os.getenv("SHOT_DIR") or "shots/ab")
.. "/" .. (os.getenv("AB_TAG") or "before")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
OverworldState.rollEncounter = function() return nil end
-- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is
-- seeded differently every launch -- so two runs of this driver put the
-- same townsfolk in different places and every shot with a person in it
-- differs for a reason that has nothing to do with the change under
-- test. Freeze them: `frozen` is the flag the NPC's own update already
-- honours, and an NPC mid-step still finishes it, so the settle below
-- lands on a still scene. They are still POSED and still drawn, so the
-- billboard, its lean and its shadow are all still under test.
local NPC = require("src.world.NPC")
if not NPC.dramaticShapeAbFreeze then
local inner = NPC.update
function NPC:update(...)
self.frozen = true
return inner(self, ...)
end
NPC.dramaticShapeAbFreeze = true
end
pcall(love.math.setRandomSeed, 20260730)
-- Freeze the tile-animation clock, on BOTH routes to it.
--
-- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the
-- same speed on a 60Hz and a 144Hz panel), which means the step a shot
-- catches depends on how fast the machine got there rather than on
-- anything the run did. Stubbing tick pins the counter the flat tile
-- layer reads.
--
-- The mod reads the SAME counter but through its own chain
-- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports
-- one, else the local off tick's upvalues, else -- and this is the trap
-- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it
-- ALONE knocks the mod onto the wall-clock fallback and makes the
-- flowers drift between two otherwise identical runs. Exporting a
-- constant animFrame takes the first branch and pins that route too.
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
-- The scenes. Chosen for what each one can BREAK, not for looks:
-- ROUTE_1 open ground, grass billboards, a long view north --
-- the case a shadow-frustum or culling change moves
-- VIRIDIAN_CITY buildings, window panes (the glass mask), signs
-- PALLET_TOWN the seam with Route 1: neighbour meshes and ring
-- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion
-- REDS_HOUSE_1F indoors: no sky, no sun, authored figures
-- PEWTER_CITY a second tileset with its own atlas bake
local SCENES = {
{ id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" },
{ id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" },
{ id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" },
{ id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" },
{ id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" },
{ id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" },
}
-- OFF is in the list deliberately: a change that speeds the 3D path up
-- must not have touched the flat one either. Then the three camera
-- rungs, 75 last because it is the low camera this performance work is
-- aimed at.
--
-- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings
-- PRESET, not a render path: it sets tilt-shift to maximum, flattens the
-- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME
-- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync),
-- which overrides this driver's pinned clock and makes every shot after
-- it depend on the wall clock. It also persists all of that, so one run's
-- FULL changes the options the NEXT run starts from. What FULL renders is
-- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers
-- exactly.
local RUNGS = {}
for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do
RUNGS[#RUNGS + 1] = tonumber(n)
end
local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0)
-- AB_SHADOW=0 renders with the sun pass's contribution turned off
-- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A
-- bisection tool: when a set of shots will not reproduce, this says
-- whether what is moving is in the shadow map or somewhere else.
if os.getenv("AB_SHADOW") == "0" then
V.require("Voxel3D").SHADOW_ALPHA = 0
end
-- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic`
-- are the SGB paths this mod bakes an atlas for, `gbc` is the shared
-- default, and `redpp` is the one that rebakes an atlas PER MAP -- four
-- genuinely different routes through TerrainAtlas.
local MODES = {}
for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do
MODES[#MODES + 1] = m
end
-- Two times of day, because half the shader only runs in one of them:
-- the window lamps, the moon disc and the night tint are all dark-only,
-- and the glint sweep and the sun disc are day-only.
local TIMES = { "day", "night" }
local shots, missed = 0, 0
-- U.shot's own mkdir is the POSIX one, which cmd.exe does not
-- understand, and a missing directory makes every capture vanish
-- silently. Try both spellings once, up front.
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
-- A capture that always costs the SAME number of frames.
--
-- U.shot spins up to 120 frames waiting for the capture to land, which
-- is right for a screenshot and wrong for this: the animated tile slots
-- (water rolling, flowers opening) ride the engine's frames-since-boot
-- counter, so a scene reached after a different number of frames renders
-- its water at a different point in the roll and the shot differs for a
-- reason no change caused. A driver resume and a rendered frame are 1:1
-- here, so the capture lands on the next draw and a fixed budget is both
-- enough and constant.
local CAPTURE_FRAMES = 4
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
-- Wait for the scene to actually BE the scene the shot is named for.
-- Two things are still in motion after a teleport, and both are timed in
-- wall-clock seconds rather than frames, so "wait N frames" settles them
-- by a different amount on every machine and every run:
--
-- the build queue -- meshes are built on a per-frame time budget, so a
-- slower run captures a half-built neighbour;
-- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot
-- taken before it lands is at some arbitrary intermediate pitch.
--
-- Both are waited on by their own completion flag, then a short fixed
-- settle. The variable wait is harmless now that the animation clock is
-- frozen above -- otherwise it would move the water instead.
-- and the CAMERA, which is the subtle one. It eases toward the player
-- over wall-clock dt, so after a fixed wait it has covered a distance
-- that depends on how fast the machine ran -- and the sun pass is only
-- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's
-- shadow signature), so a frame caught mid-ease carries a shadow map
-- fitted for a slightly different camera than the one it is drawn with.
-- That is a real and deliberate tolerance in the mod, but it makes the
-- gate compare two arbitrary points inside it. Waiting for the camera
-- to stop moving entirely puts every shot at the same steady state.
local function cameraStill()
local o = game.overworld
local c = o and o.camera
if not c then return true end
local lx, ly, held = nil, nil, 0
for _ = 1, 300 do
if c.x == lx and c.y == ly then
held = held + 1
if held >= 10 then return true end
else
held = 0
lx, ly = c.x, c.y
end
U.wait(1)
end
return false
end
local function settleBuild()
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
cameraStill()
-- and then force one final sun pass at the settled camera. The map is
-- only redrawn when the camera crosses a quarter world pixel, so a
-- still camera holds whatever was drawn at the moment it last did --
-- correct to within that tolerance, but fitted from a position that
-- depends on where the easing happened to be, which differs by a few
-- hundredths of a pixel between runs and moves every shadow edge by a
-- shade or two. Forgetting the stamp redraws from the state the shot
-- is actually taken in, and two runs then agree exactly.
-- guarded so this driver can also be pointed at a build that predates
-- the seam, which is exactly what capturing a "before" reference means
if ShadowMap.forget then ShadowMap.forget() end
U.wait(20)
end
-- AB_TRACE=1 prints the state each shot was taken in. When two runs of
-- this driver disagree, this is what says which input moved.
local TRACE = os.getenv("AB_TRACE") == "1"
local function trace(name)
if not TRACE then return end
local o = game.overworld
local e = ShadowMap.extent or {}
print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d")
:format(name,
o and o.camera and o.camera.x or -1,
o and o.camera and o.camera.y or -1,
o and o.player and o.player.px or -1,
o and o.player and o.player.py or -1,
ShadowMap.res or 0,
e[1] or 0, e[2] or 0, e[3] or 0,
ShadowMap.KX or 0, ShadowMap.KZ or 0,
Voxel.angle or 0,
ChunkMesher.pending()))
end
-- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save
-- directory. When the scene differs but every input to the sun pass is
-- identical, the map is the only place left to look.
local DUMP = os.getenv("AB_SHADOWDUMP") == "1"
local function dumpShadow(name)
if not DUMP then return end
local tex = ShadowMap.texture()
if not (tex and tex.newImageData) then return end
pcall(function()
love.filesystem.createDirectory("ab_shadow")
tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png")
end)
end
local function capture(name)
trace(name)
dumpShadow(name)
local path = ("%s/%s.png"):format(ROOT, name)
game.capturePath = path
U.wait(CAPTURE_FRAMES)
local f = io.open(path, "rb")
if f then
f:close()
shots = shots + 1
else
missed = missed + 1
print("[ab] capture did not reach disk: " .. path)
end
end
local PaletteFX = require("src.render.PaletteFX")
local function setMode(mode)
local known = false
for _, m in ipairs(PaletteFX.MODES) do
if m == mode then known = true break end
end
if not known then return false end
return (pcall(PaletteFX.setMode, mode))
end
-- A fixed zoom, so the view size every shot is composed at is the same
-- one. Zoom is persisted, so without this a session that ever ran the
-- FULL preset (which fits the zoom to the window) leaves a different
-- view size behind for every later run.
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
for _, mode in ipairs(MODES) do
if setMode(mode) then
for _, when in ipairs(TIMES) do
DayNight.setting:sync(when)
for _, s in ipairs(SCENES) do
for _, rung in ipairs(RUNGS) do
U.teleport(game, s.id, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
-- pinned AFTER the voxel rung, because FULL is a preset that
-- reaches over and sets this row itself (main.lua's applyFull)
-- and persists it -- so without this, one run's FULL leaks a
-- blur level into the NEXT run's options.lua and every shot
-- differs for a reason no code change caused. Sharp by
-- default: a gaussian smears a one-pixel geometry difference
-- across the whole frame, which is exactly what a gate meant
-- to localise differences must not do. AB_TSHIFT=3 runs the
-- blur path deliberately.
Pipelines.setLevel("tiltshift", TSHIFT)
-- Wait for the build queue to drain rather than for a fixed
-- number of frames. Meshes are built on a per-frame time
-- budget, so how much of a map exists after N frames is a
-- property of the MACHINE -- a slower run captures a
-- half-built neighbour and the shot differs for no reason the
-- change caused. Draining first, then settling a fixed 40
-- frames for the camera tween, makes the scene the same
-- scene everywhere. (Safe now that the animation clock above
-- is frozen: a variable wait no longer moves the water.)
settleBuild()
capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung))
end
end
end
else
print("[ab] display mode " .. mode .. " unavailable, skipped")
end
end
print(("[ab] %d shots into %s (%d failed to reach disk)")
:format(shots, ROOT, missed))
end