mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 11:50:50 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da8e5dc3e | |||
| 26d1d96d52 | |||
| d9a000d7ad | |||
| f12b564dcd | |||
| d1a1c69c7d | |||
| 500556c3fc | |||
| e621c28a74 | |||
| eabc8af716 | |||
| 785838e6cd | |||
| 14f844ad8e | |||
| cb325af6cf | |||
| 50f8f6bfe3 | |||
| 284172db7b | |||
| 6d27d91411 | |||
| e898cedad6 | |||
| f2a18b62b2 | |||
| 5ff56eab46 | |||
| 99e18f7b5b | |||
| 9c263f0937 | |||
| 7e3245a9ef | |||
| 18857e039a | |||
| f841378330 | |||
| 6d7a93dbad | |||
| e14953c78a | |||
| 8dc046f72e | |||
| c3ba10e17a | |||
| 41a86b5fb2 | |||
| 9a34ba9c77 | |||
| 097bdcd14b | |||
| e838ff4558 | |||
| 8e7a80055d | |||
| 5ea70a278f | |||
| 60d5362c89 | |||
| 3c1781f6b1 | |||
| b21fd46ea7 | |||
| 917fbac38c | |||
| 0f303e7975 | |||
| d8d1d7e336 | |||
| 4e6aa30c2c | |||
| 2e0ae37bcd | |||
| 9207712b19 | |||
| 2447046aee | |||
| 458a1d7bf9 | |||
| 5781c28b60 | |||
| 6280cdffe7 | |||
| acc66b51bb | |||
| 894a4b4a55 | |||
| 2a4269b20e |
@@ -4,6 +4,8 @@
|
||||
# The SDK suite and the probes it grew out of. A shipped test that requires
|
||||
# an engine module reads as a private require against the archive
|
||||
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
|
||||
tests/arena_pick.lua
|
||||
tests/battle_shots.lua
|
||||
tests/dramatic_shape_test.lua
|
||||
tests/voxel_anim_probe.lua
|
||||
tests/voxel_door_probe.lua
|
||||
@@ -19,6 +21,7 @@ tests/voxel_void_probe.lua
|
||||
# ROM, read the local cache, and emit assets/voxels/*.lua; the carved models
|
||||
# are what a player installs, the carving is not.
|
||||
tools/build_voxels.py
|
||||
tools/contact_sheets.py
|
||||
tools/building_images.py
|
||||
tools/building_voxels.py
|
||||
tools/voxel-survey.md
|
||||
|
||||
+557
-1
@@ -1,6 +1,562 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 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
|
||||
|
||||
- **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
|
||||
|
||||
- A move that shakes the screen no longer whites out the frame. The zone pass
|
||||
fills each zone with its blank colour before drawing the shifted copy -- the
|
||||
hardware showing empty BG in the strip the shake vacated -- and a shake
|
||||
program alternates offset and no-offset frames, so over the map that read as
|
||||
the whole battle screen, menu box included, flashing white a few times a
|
||||
second. The fill is dropped while a battle is staged on the map; the shake
|
||||
itself still moves the HUD.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### 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
|
||||
on the map -- rather than making a player assemble it from four rows.
|
||||
|
||||
While it is selected, every row it owns comes OFF the menu: V-GRID,
|
||||
V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is
|
||||
worse than no row. Stepping onto or off `FULL` rebuilds the open menu in
|
||||
place, so the rows leave and return under the cursor instead of waiting
|
||||
for the menu to be reopened.
|
||||
|
||||
It applies its settings when the row ARRIVES at `FULL`, not every frame:
|
||||
holding them would make the zoom keys and the wheel dead while it was on.
|
||||
Leaving it deliberately undoes nothing -- reverting would discard whatever
|
||||
had been changed since.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The hit flash whited out the whole screen.** The engine draws it as a
|
||||
full-screen white rectangle, which is a flash on a white battle field and
|
||||
a whiteout of the map, the HUD and the text box over a world. It is now
|
||||
dropped on the way past and put back where it was ever about: the two
|
||||
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
|
||||
mid-walk would silently push the blur to maximum and flatten the horizon
|
||||
with nothing on screen saying a keypress had done it. `FULL` stays on the
|
||||
OPTIONS row, where a preset that changes other rows belongs.
|
||||
|
||||
A press FROM `FULL` goes to `50`. `FULL` is already the 35-degree camera,
|
||||
so stepping to the rung of that name would look like the key had done
|
||||
nothing. Matched by angle, so it follows `FULL` if that is ever retuned.
|
||||
|
||||
- **The mode's four options are one block in the menu.** The engine splices
|
||||
a pipeline row in beside TILT and lands a mod's own rows at the end of the
|
||||
list, which had these four in two places with unrelated engine rows
|
||||
between them. The settings now follow the pipeline rows directly.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Battles happen on the map you were standing on.** The battle screen's
|
||||
white field is replaced by the world: the mod finds the nearest patch of
|
||||
open ground, points a placed over-the-shoulder camera at it, and draws the
|
||||
fight over that. New **3D-BTL** row and hotkey `8`, on by default.
|
||||
|
||||
The arena is a 3x6 clearing of cells the player could walk on, with the
|
||||
two mons three cells apart down the middle column and a one-cell apron all
|
||||
round so the camera looks across floor rather than into a wall. Where no
|
||||
map has room for that -- a corridor, a cave, a shop -- the search relaxes
|
||||
to a 1x4 corridor with the apron given up, and where even that will not
|
||||
fit the battle draws exactly as it always did.
|
||||
|
||||
Everything else in the frame is the engine's own. The mon pics, HUDs, HP
|
||||
bars, move animations, faint slides and text box are drawn by BattleState,
|
||||
in its order, at its coordinates -- the GB's own layout, with the player's
|
||||
mon low and left and the enemy's high and right, which is why the camera
|
||||
is placed east of the arena axis rather than the layout being moved to
|
||||
suit the camera. What changes is what is behind them.
|
||||
|
||||
Three things carry the shot. The overworld's cast is culled before the
|
||||
wipe, so it plays over an empty map and no bystander is standing in the
|
||||
arena. The camera drifts on a slow orbit about a point between the two
|
||||
mons, which moves the near ground and the far ground by different amounts
|
||||
-- parallax, not a sliding backdrop. And a depth-of-field pass holds the
|
||||
band of frame the two mons stand in sharp and softens the middle distance
|
||||
and the foreground; both mons are in focus by construction, because they
|
||||
are drawn as the battle screen's own pics after the pass has run.
|
||||
|
||||
**Nobody moves.** The arena is where the CAMERA goes. Nothing here writes
|
||||
a cell, a facing, a flag or a warp, so a trainer's post-battle dialogue is
|
||||
still talking to someone standing in front of them, and the blackout path,
|
||||
sight lines and every script find the player exactly where they left them.
|
||||
|
||||
The two HUD blocks gain the backing the white field used to be. Gen 1
|
||||
draws them as black glyphs straight onto the background with no box round
|
||||
them, and black-on-grass is not readable; the backing is painted inside
|
||||
`drawHUDs`, so it lands in the same target and takes the same zone colour
|
||||
as the HUD it sits under, in both the colorized and flat pipelines.
|
||||
|
||||
Declines cleanly at every step it cannot take: no depth support, no open
|
||||
ground, the row switched off, or a terrain mesh still building all end at
|
||||
the battle screen the engine has always drawn.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -9,10 +9,18 @@ as leaning sprite slabs, a shadow map throws real cast shadows across
|
||||
whatever they land on, and an optional tilt-shift pass sells the
|
||||
miniature-model look.
|
||||
|
||||
And battles fought on that world rather than on a white field. When
|
||||
something picks a fight the map's NPCs are culled, the engine's own wipe
|
||||
plays over the empty map, and the battle draws over the nearest patch of
|
||||
clear ground — shot over the shoulder, the player's mon low and left and
|
||||
the enemy high and right, with a slow parallax drift behind them and a
|
||||
depth-of-field pass that keeps both of them sharp.
|
||||
|
||||
Purely presentational. Nothing here reaches collision, movement, triggers
|
||||
or scripts — it changes what the world *looks* like and nothing about what
|
||||
it *is*. Battles, menus and cutscenes are untouched; only the free-roam
|
||||
overworld draws differently.
|
||||
it *is*. The battle arena is where the **camera** goes, not where anybody
|
||||
goes: no cell, facing, flag or warp is written, so the player is standing
|
||||
exactly where the fight found them when it ends.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -25,8 +33,22 @@ menu.
|
||||
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
|
||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
||||
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
|
||||
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Where each map's battles happen.
|
||||
--
|
||||
-- One authored spot per area, rather than whichever clearing happens to be
|
||||
-- nearest the step the fight started on. A battle on Route 1 should look the
|
||||
-- same every time it happens on Route 1 -- and, more importantly, "open
|
||||
-- ground" is not the same question as "you can see the two of them": the
|
||||
-- battle camera sits low and a long way back, so a hedge, a ledge lip or the
|
||||
-- corner of a house anywhere along that sightline hides a Pokemon completely
|
||||
-- while every cell it is standing on is perfectly walkable.
|
||||
--
|
||||
-- Each entry is the arena's north-west corner in map CELLS, and which of
|
||||
-- BattleArena.SHAPES it is. Picked by tools/arena_pick (BattleArena.search
|
||||
-- with the clearance test on, from the middle of the map) and then looked at,
|
||||
-- one screenshot per map -- these are eyeballed answers, not just passing
|
||||
-- ones. Grass and flowers around a mon's feet are fine and wanted; anything
|
||||
-- that cuts into a body is not.
|
||||
--
|
||||
-- A map with no entry here falls back to the nearest-clear search at battle
|
||||
-- time, so a mod that adds maps, or an entry that goes stale against an
|
||||
-- edited map, degrades to the old behaviour rather than to no battle.
|
||||
--
|
||||
-- The entries below are generated; regenerate with
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/arenas \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
|
||||
|
||||
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
|
||||
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
|
||||
-- on the same screen anchors either way -- so it is purely a choice about how
|
||||
-- much of the place is in shot, at the cost of a smaller pair. Rooms the long
|
||||
-- lens cannot stand back from NEED it; anywhere that simply reads better with
|
||||
-- more of itself visible may ask for it.
|
||||
--
|
||||
-- Tall grass is never part of an arena (BattleArena.openCell rejects it), so
|
||||
-- a route's spot is always its bare path rather than the field beside it --
|
||||
-- grass is knee-high geometry to a Pokemon and this camera is nearly level
|
||||
-- with the floor, so tufts on the mon's own tile stand between it and the
|
||||
-- lens. Flowers are left alone: they are ankle height and read as ground.
|
||||
|
||||
return {
|
||||
-- ------- routes
|
||||
-- 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" },
|
||||
["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
|
||||
["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
|
||||
["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
|
||||
["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
|
||||
-- the whole route admits six bare wide arenas, all in the west cliff
|
||||
-- corridor; this is the best of them. A flower cluster crosses the far
|
||||
-- mon's hind legs, which the brief allows -- every alternative put a
|
||||
-- terrace through the near mon's waist, which it does not.
|
||||
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
|
||||
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
|
||||
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
|
||||
["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
|
||||
["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
|
||||
["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
|
||||
["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
|
||||
["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
|
||||
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
|
||||
["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
|
||||
|
||||
-- ------- buildings and caves
|
||||
--
|
||||
-- Indoors the narrow shape earns its keep: a room with furniture, machinery
|
||||
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
|
||||
-- also SEEN, and giving up the apron is usually the difference between a
|
||||
-- fight in the open and one behind a console.
|
||||
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
|
||||
["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
|
||||
["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
|
||||
["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
|
||||
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
|
||||
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
|
||||
-- every one of 4F's thirty candidate spots puts a gravestone through a
|
||||
-- mon; the floor below is the same tower and has the room, so the fight
|
||||
-- is shot there
|
||||
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
|
||||
shape = "wide" },
|
||||
["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
|
||||
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
|
||||
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
|
||||
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
|
||||
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
|
||||
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
|
||||
["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
|
||||
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
|
||||
["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
|
||||
["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
|
||||
["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
|
||||
-- Silph Co is office floors partitioned into small rooms, so the long lens
|
||||
-- often lands outside the walls it is meant to be looking between; the
|
||||
-- floors that could not be framed any other way ask for the wide one.
|
||||
["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
|
||||
["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
|
||||
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
|
||||
["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
|
||||
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
|
||||
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
|
||||
["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
|
||||
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
|
||||
-- aboard and the long lens always lands outside the hull
|
||||
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
|
||||
-- these two decks are byte-identical geometry, so they take the same spot
|
||||
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
|
||||
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
|
||||
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
|
||||
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
|
||||
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
|
||||
|
||||
-- ------- towns and the last interiors
|
||||
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
|
||||
["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
|
||||
-- the lab is ten cells by twelve, so the long lens is always off-map, and
|
||||
-- on it a desk clipped one mon and a pillar the other
|
||||
["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
|
||||
-- Saffron's gym is a grid of small walled cells: no wide shape exists
|
||||
-- anywhere in it, and the long lens sits inside a divider
|
||||
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
|
||||
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
|
||||
["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
|
||||
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
|
||||
-- gym's barrier, so arenas there silently fail the fit test
|
||||
["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
|
||||
["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
|
||||
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
|
||||
|
||||
-- ------- the remaining routes
|
||||
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
|
||||
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
|
||||
-- than on the rim of beach the land search would otherwise find
|
||||
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
|
||||
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
|
||||
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
|
||||
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
|
||||
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
|
||||
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
|
||||
|
||||
-- ------- caves, gyms and the Elite Four
|
||||
--
|
||||
-- None of these tilesets has a grass tile at all, so the no-grass rule
|
||||
-- constrained nothing here; what constrains them is furniture, rock
|
||||
-- pillars and how small the rooms are.
|
||||
["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
|
||||
["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
|
||||
["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
|
||||
["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
|
||||
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
|
||||
-- hide a mon, so it borrows the floor below -- the same cave
|
||||
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
|
||||
shape = "wide" },
|
||||
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
|
||||
["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
|
||||
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
|
||||
["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
|
||||
["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
|
||||
["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
|
||||
["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
|
||||
["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
|
||||
["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
|
||||
["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
|
||||
["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
|
||||
|
||||
-- The three gyms the default rig cannot stand back from. Five blocks is
|
||||
-- further than these rooms are wide, so the eye landed outside the map and
|
||||
-- the border ring -- extruded into a cliff by this mode -- crossed the near
|
||||
-- mon wherever it stood. `cam = "wide"` swaps in the 44-degree lens (see
|
||||
-- BattleCam), which fits inside the room; the mons come out smaller and all
|
||||
-- three became stageable. It is asked for HERE, per map, so every area that
|
||||
-- does not ask keeps the long lens it was framed for.
|
||||
["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
|
||||
["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
|
||||
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
|
||||
}
|
||||
+442
-197
@@ -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.
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
-- Overworld battles: where the fight is staged.
|
||||
--
|
||||
-- A battle in this mod happens ON THE MAP, so it needs a patch of ground
|
||||
-- clear enough to stand two Pokemon on and point a camera down. This module
|
||||
-- finds it: the nearest patch of open cells, in the shape below.
|
||||
--
|
||||
-- x x x
|
||||
-- x O x O the enemy's mon
|
||||
-- x x x
|
||||
-- x x x
|
||||
-- x P x P the player's mon
|
||||
-- x x x
|
||||
--
|
||||
-- Every `x` is an OPEN cell -- one with no obstruction, i.e. one the player
|
||||
-- could walk onto. The two mons stand three cells apart down the middle
|
||||
-- column, with a one-cell apron all round so the camera looks across floor
|
||||
-- rather than into a wall.
|
||||
--
|
||||
-- When no map has room for that -- a corridor, a cave, a shop floor -- the
|
||||
-- search relaxes to the narrow shape, which is the same three-cell gap with
|
||||
-- the apron given up:
|
||||
--
|
||||
-- O
|
||||
-- x
|
||||
-- x
|
||||
-- P
|
||||
--
|
||||
-- and if even that will not fit, the caller gets nil and the battle draws
|
||||
-- the way it always did. A mod that cannot find a stage does not invent
|
||||
-- one.
|
||||
--
|
||||
-- Nothing here MOVES anybody: the arena is where the CAMERA goes and where
|
||||
-- the two mons are staged for the shot. The player's own cell, the party,
|
||||
-- every script and flag are exactly where the battle left them, which is
|
||||
-- what keeps a trainer's post-battle dialogue talking to someone still
|
||||
-- standing in front of them.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local BattleArena = {}
|
||||
|
||||
-- ------- the authored spot
|
||||
--
|
||||
-- Every map gets ONE place its battles happen, chosen once and written down
|
||||
-- in data/battle_arenas.lua, rather than whatever clearing happens to be
|
||||
-- nearest to wherever the fight started. Two reasons.
|
||||
--
|
||||
-- A fight should look the same every time it happens somewhere. Picking the
|
||||
-- nearest patch means Route 1 has a dozen different battle scenes depending
|
||||
-- on which step of the grass you were on, some of them behind a tree.
|
||||
--
|
||||
-- And "open ground" is not the same question as "you can SEE the two of
|
||||
-- them". The camera is low and a long way back, so a hedge, a ledge lip or a
|
||||
-- building corner anywhere along that line hides a mon completely while the
|
||||
-- cells it stands on are perfectly walkable. That is what `clearance` below
|
||||
-- measures, and it is what the authored list is chosen against.
|
||||
--
|
||||
-- A map with no entry falls back to the search, so a mod that adds maps, or
|
||||
-- an entry that goes stale, degrades to the old behaviour rather than to no
|
||||
-- battle.
|
||||
-- An entry may also name ANOTHER MAP to stage on:
|
||||
--
|
||||
-- ["MT_MOON_B2F"] = { map = "MT_MOON_1F", x = 12, y = 8, shape = "wide" }
|
||||
--
|
||||
-- because some maps simply have nowhere to put a fight. A cave's lower floor
|
||||
-- can be nothing but two-cell-wide corridors between rock walls; a gym is a
|
||||
-- room full of furniture. Rather than stage a battle there badly -- both
|
||||
-- Pokemon behind a boulder -- the fight is shot on a floor of the SAME cave,
|
||||
-- or a floor of the same building, that does have the room for it. It is the
|
||||
-- same place, and no worse a fiction than a battle happening on ground the
|
||||
-- player is not standing on, which is what every one of these already is.
|
||||
local authored = nil
|
||||
local overrides = {}
|
||||
|
||||
local function authoredFor(mapId)
|
||||
-- `~= nil`, not truthiness: `false` is a meaningful entry here (an
|
||||
-- authored refusal), so it has to reach the caller rather than read as
|
||||
-- "nothing set" and fall through to the data file
|
||||
local forced = overrides[mapId]
|
||||
if forced ~= nil then return forced end
|
||||
if authored == nil then
|
||||
local ok, list = pcall(V.data, "battle_arenas")
|
||||
authored = (ok and type(list) == "table") and list or false
|
||||
end
|
||||
if not authored then return nil end
|
||||
return authored[mapId]
|
||||
end
|
||||
|
||||
BattleArena.authoredFor = authoredFor
|
||||
|
||||
-- Force one map's entry at runtime, ahead of the data file. The authoring
|
||||
-- tool's handle: it is how a spot chosen by eye is staged and photographed
|
||||
-- before it is written down, and the only way to check a cross-floor entry
|
||||
-- without editing the shipped list first. Pass nil to drop it again.
|
||||
function BattleArena.setOverride(mapId, entry)
|
||||
overrides[mapId] = entry
|
||||
end
|
||||
|
||||
-- Cell size in world pixels, the unit every coordinate here is in when it
|
||||
-- crosses into the renderer (Map's walk grid is 16px cells).
|
||||
local CELL = 16
|
||||
|
||||
-- The two shapes, in preference order. `w`/`h` are in cells; `enemy` and
|
||||
-- `player` are the offsets, from the shape's north-west corner, of the two
|
||||
-- cells a mon stands on.
|
||||
BattleArena.SHAPES = {
|
||||
{ id = "wide", w = 3, h = 6, enemy = { 1, 1 }, player = { 1, 4 } },
|
||||
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
|
||||
}
|
||||
|
||||
-- Whether a cell is open ground for the purpose above.
|
||||
--
|
||||
-- "Open" is the walk test the player themselves answer to, so an arena can
|
||||
-- never be laid over a wall, a counter, a tree or a ledge face. Water counts
|
||||
-- only for a surfer, which is the one case where the player is standing on
|
||||
-- it too -- a sea battle staged on the beach half a route away would read as
|
||||
-- a teleport.
|
||||
--
|
||||
-- Warp cells are excluded on top of that. They are walkable by definition
|
||||
-- (they are the doormat), and a fight framed in a doorway both looks wrong
|
||||
-- and puts the camera inside the building's geometry.
|
||||
--
|
||||
-- And TALL GRASS is excluded, which is the surprising one, because grass is
|
||||
-- where wild battles come from and standing in it is the obvious place to
|
||||
-- have one. It does not survive contact with the camera. Grass is real
|
||||
-- geometry in this mode -- a row of tufts about knee height on a Pokemon --
|
||||
-- drawn with the same camera-ward bias that lets it overdraw a walking
|
||||
-- character's feet in the free-roam world. From a camera nearly level with
|
||||
-- the floor that bias stops being feet-deep: the tufts on and around a mon's
|
||||
-- own tile stand between it and the lens and eat most of the sprite.
|
||||
--
|
||||
-- So the arena is laid on bare ground -- the whole footprint, not just the
|
||||
-- two cells a mon stands on, because the apron south of the near mon is
|
||||
-- exactly the row whose grass would cover it. Grass FURTHER back toward the
|
||||
-- camera is fine and stays: it is far enough forward to project low and wide
|
||||
-- across the bottom of the frame, where it reads as a field rather than as
|
||||
-- something in the way.
|
||||
local function openCell(map, cx, cy, surfing)
|
||||
if not map:inBounds(cx, cy) then return false end
|
||||
if map:warpAtCell(cx, cy) then return false end
|
||||
if map:isWarpTileCell(cx, cy) then return false end
|
||||
if map.isGrassCell and map:isGrassCell(cx, cy) then return false end
|
||||
if map:isWalkableCell(cx, cy) then return true end
|
||||
return (surfing and map:isWaterCell(cx, cy)) or false
|
||||
end
|
||||
|
||||
BattleArena.openCell = openCell
|
||||
|
||||
-- The map's open cells as one flat boolean grid, so the rectangle test
|
||||
-- below is a lookup rather than a tileset walk per cell. Built once per
|
||||
-- search; a battle asks for one.
|
||||
local function openGrid(map, surfing)
|
||||
local w, h = map.widthCells, map.heightCells
|
||||
local grid = {}
|
||||
for cy = 0, h - 1 do
|
||||
local row = cy * w
|
||||
for cx = 0, w - 1 do
|
||||
grid[row + cx] = openCell(map, cx, cy, surfing)
|
||||
end
|
||||
end
|
||||
return grid, w, h
|
||||
end
|
||||
|
||||
local function fits(grid, gw, x, y, w, h)
|
||||
for cy = y, y + h - 1 do
|
||||
local row = cy * gw
|
||||
for cx = x, x + w - 1 do
|
||||
if not grid[row + cx] then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Build the record the renderer reads: the two mons' cells and, in world
|
||||
-- pixels, the centre of each and of the pair.
|
||||
local function place(shape, x, y)
|
||||
local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
|
||||
local px, py = x + shape.player[1], y + shape.player[2]
|
||||
local arena = {
|
||||
shape = shape.id,
|
||||
x = x, y = y, w = shape.w, h = shape.h,
|
||||
enemyCell = { ex, ey },
|
||||
playerCell = { px, py },
|
||||
-- world-pixel centres of the two cells a mon stands on
|
||||
enemy = { ex * CELL + CELL / 2, ey * CELL + CELL / 2 },
|
||||
player = { px * CELL + CELL / 2, py * CELL + CELL / 2 },
|
||||
}
|
||||
arena.mid = { (arena.enemy[1] + arena.player[1]) / 2,
|
||||
(arena.enemy[2] + arena.player[2]) / 2 }
|
||||
return arena
|
||||
end
|
||||
|
||||
-- ------- can the two of them actually be SEEN there
|
||||
--
|
||||
-- The camera sits low and far back on one side, so what hides a mon is not
|
||||
-- what is on its own tile -- it is anything TALL between the camera and it.
|
||||
-- A tree two cells to the south-east blocks the near mon completely while
|
||||
-- every cell of the arena is open ground.
|
||||
--
|
||||
-- So the line from the eye to each mon is walked in short steps and the
|
||||
-- terrain height under each step is compared with how high the line is
|
||||
-- there. Three lines per mon -- to its feet, its middle and its head --
|
||||
-- because a hedge that clears the head still cuts the body in half.
|
||||
--
|
||||
-- Grass and flowers are deliberately not obstacles: they stand at ankle
|
||||
-- height, they are what a field looks like, and a mon standing in them
|
||||
-- reads as standing in a field rather than as being hidden by one.
|
||||
BattleArena.SAMPLE_STEP = 4 -- world pixels along the line
|
||||
BattleArena.MON_H = 16 -- how tall a mon stands, in world pixels
|
||||
BattleArena.CLEAR_EPS = 1.5 -- slack, so a flush kerb is not an obstacle
|
||||
|
||||
local function heightAt(map, wx, wz)
|
||||
local cx, cy = math.floor(wx / CELL), math.floor(wz / CELL)
|
||||
if not map:inBounds(cx, cy) then
|
||||
-- off the map the border ring is drawn, and on most outdoor maps that
|
||||
-- ring is trees; treat it as solid so an arena is never framed through it
|
||||
return 32
|
||||
end
|
||||
local ok, h = pcall(V.require("VoxelScene").groundAt, map, cx, cy)
|
||||
return (ok and h) or 0
|
||||
end
|
||||
|
||||
-- Whether the segment from `eye` to (tx, ty, tz) clears the terrain.
|
||||
local function lineClear(map, eye, tx, ty, tz)
|
||||
local dx, dy, dz = tx - eye[1], ty - eye[2], tz - eye[3]
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len <= 1 then return true end
|
||||
local steps = math.ceil(len / BattleArena.SAMPLE_STEP)
|
||||
-- skip the ends: the eye is in open air by construction and the last step
|
||||
-- is the mon's own tile, which it is standing on
|
||||
for i = 1, steps - 1 do
|
||||
local t = i / steps
|
||||
local wx = eye[1] + dx * t
|
||||
local wy = eye[2] + dy * t
|
||||
local wz = eye[3] + dz * t
|
||||
if heightAt(map, wx, wz) > wy + BattleArena.CLEAR_EPS then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Whether both mons would be in plain view from the battle camera.
|
||||
function BattleArena.clearance(map, arena)
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0)
|
||||
if not (ok and rig and rig.eye) then return true end
|
||||
local eye = rig.eye
|
||||
local H = BattleArena.MON_H
|
||||
for _, mark in ipairs({ arena.player, arena.enemy }) do
|
||||
for _, hy in ipairs({ 1, H * 0.5, H }) do
|
||||
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The nearest arena to (fromX, fromY) -- the player's cell -- or nil when
|
||||
-- the map has room for neither shape.
|
||||
--
|
||||
-- Distance is measured from the player to the arena's MIDPOINT, so "nearest"
|
||||
-- means the fight is staged as close to where it was triggered as the ground
|
||||
-- allows, rather than merely having a corner nearby.
|
||||
--
|
||||
-- Both shapes are searched over the whole map before the next one is tried:
|
||||
-- a wide arena on the far side of a route still beats a narrow one
|
||||
-- underfoot, because the wide one is the shot this mode is framed for.
|
||||
function BattleArena.find(map, fromX, fromY, surfing)
|
||||
if not (map and map.widthCells) then return nil end
|
||||
|
||||
-- the authored spot wins outright when the map has one and it still holds
|
||||
local pick = authoredFor(map.id)
|
||||
-- `false` is an authored REFUSAL: a map looked at and found to have nowhere
|
||||
-- a fight can be seen, with no other floor to borrow. Declining is the
|
||||
-- honest answer -- the battle draws on the plain screen -- and it has to be
|
||||
-- said explicitly, because the fallback search below would otherwise go and
|
||||
-- find one of the bad spots that were already rejected by eye.
|
||||
if pick == false then return nil end
|
||||
if pick then
|
||||
local shape = nil
|
||||
for _, s in ipairs(BattleArena.SHAPES) do
|
||||
if s.id == (pick.shape or "wide") then shape = s end
|
||||
end
|
||||
-- an entry may point at another floor of the same cave or building; the
|
||||
-- arena is then measured against THAT map, and carries it
|
||||
local host = map
|
||||
if shape and pick.map and pick.map ~= map.id then
|
||||
local ok, other = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return require("src.world.MapLoader").load(Game.data, pick.map)
|
||||
end)
|
||||
host = (ok and other) or nil
|
||||
end
|
||||
if shape and host then
|
||||
-- An authored spot is checked with WATER COUNTING AS GROUND, whatever
|
||||
-- the player is doing. The surfing test exists to stop the automatic
|
||||
-- search staging a walker's fight out at sea; an authored entry was
|
||||
-- chosen and looked at by a person, so if it is on water that is the
|
||||
-- point of it -- the surf routes fight in the middle of their own
|
||||
-- ocean rather than on a scrap of beach at the edge of the map. Land
|
||||
-- entries are unaffected: land passes the test either way.
|
||||
local grid, gw = openGrid(host, true)
|
||||
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
|
||||
local arena = place(shape, pick.x, pick.y)
|
||||
arena.map = host
|
||||
-- which camera rig this spot is framed for; nil is the default long
|
||||
-- lens, "close" the short one small rooms need (see BattleCam)
|
||||
arena.cam = pick.cam
|
||||
return arena
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local found = BattleArena.search(map, fromX, fromY, surfing)
|
||||
if found then found.map = map end
|
||||
return found
|
||||
end
|
||||
|
||||
-- The arena at a given north-west corner, whatever the map says about it.
|
||||
-- The authoring tool's manual override: a spot chosen by eye rather than by
|
||||
-- the search, so it can be photographed and judged before it is written down.
|
||||
function BattleArena.at(x, y, shapeId)
|
||||
for _, shape in ipairs(BattleArena.SHAPES) do
|
||||
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The nearest arena the map can offer, preferring one the pair can be SEEN
|
||||
-- in. Two passes rather than one score: a clear arena on the far side of a
|
||||
-- route beats an obstructed one underfoot, because being able to see the
|
||||
-- fight is the point, but an obstructed one still beats no battle at all.
|
||||
function BattleArena.search(map, fromX, fromY, surfing, wantClear)
|
||||
local grid, gw, gh = openGrid(map, surfing)
|
||||
for _, shape in ipairs(BattleArena.SHAPES) do
|
||||
for _, needClear in ipairs({ true, false }) do
|
||||
local best, bestD = nil, nil
|
||||
for y = 0, gh - shape.h do
|
||||
for x = 0, gw - shape.w do
|
||||
if fits(grid, gw, x, y, shape.w, shape.h) then
|
||||
local mx = x + (shape.w - 1) / 2
|
||||
local my = y + (shape.h - 1) / 2
|
||||
local dx, dy = mx - fromX, my - fromY
|
||||
local d = dx * dx + dy * dy
|
||||
if not bestD or d < bestD then
|
||||
local cand = place(shape, x, y)
|
||||
if not needClear or BattleArena.clearance(map, cand) then
|
||||
best, bestD = cand, d
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best then return best end
|
||||
if wantClear and needClear then return nil end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return BattleArena
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Overworld battles: the two mons, as geometry standing on the map.
|
||||
--
|
||||
-- Not pics composited over a picture of the world -- quads INSIDE it, drawn
|
||||
-- in the same 3D pass as the terrain, from the same camera, through the same
|
||||
-- shader. Which means they get everything the world gets and nothing has to
|
||||
-- be faked for them: the depth buffer decides what is in front of what, the
|
||||
-- sun pass sees them and throws their real silhouettes across the ground,
|
||||
-- and their size on screen is whatever standing on that tile at that
|
||||
-- distance actually looks like.
|
||||
--
|
||||
-- One quad, standing upright with its feet on the cell and yawed to face the
|
||||
-- camera. Upright rather than leaned back, unlike the free-roam mode's
|
||||
-- character cards: those lean because that camera looks DOWN and a standing
|
||||
-- card would foreshorten to nothing, and this one looks along the ground
|
||||
-- from about a foot above it, where a card standing up is simply correct.
|
||||
--
|
||||
-- The shader's alpha discard cuts the mon's exact outline out of the quad,
|
||||
-- so a Pokemon is its own silhouette against the world with no matte, no
|
||||
-- billboard edge and no sorting to get wrong.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
|
||||
local BattleBillboard = {}
|
||||
|
||||
-- How wide a full-size (7x7 tile) mon stands, in world pixels. One overworld
|
||||
-- square, so a Pokemon covers the tile it is on and no more; a species whose
|
||||
-- pic is smaller than the full buffer comes out proportionally smaller,
|
||||
-- which is how the artwork's own size differences survive the trip.
|
||||
BattleBillboard.FULL_W = 16
|
||||
BattleBillboard.FULL_PIC = 56 -- the pic size FULL_W refers to
|
||||
|
||||
-- A hair of camera-ward bias, so a card standing ON the ground plane wins
|
||||
-- the depth test against it instead of z-fighting the tile it is rooted to.
|
||||
BattleBillboard.PULL = 1.5
|
||||
|
||||
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 = {
|
||||
{ -0.5, 0, 0, 0, 1, 1 },
|
||||
{ 0.5, 0, 0, 1, 1, 1 },
|
||||
{ 0.5, 1, 0, 1, 0, 1 },
|
||||
{ -0.5, 1, 0, 0, 0, 1 },
|
||||
}
|
||||
local indices = {}
|
||||
Voxel3D.pushQuad(indices, 0)
|
||||
local mesh = Voxel3D.newMesh(verts, indices)
|
||||
quad = mesh or false
|
||||
return quad or nil
|
||||
end
|
||||
|
||||
BattleBillboard.mesh = unitQuad
|
||||
|
||||
-- The yaw that turns the card's face toward the eye. The quad's normal is
|
||||
-- +Z before rotation, so this is just the bearing from the mon to the
|
||||
-- camera -- flattened to the horizontal, because a card that also tipped to
|
||||
-- face a camera above it would lift its feet off the floor.
|
||||
function BattleBillboard.yawToward(x, z, eye)
|
||||
if not eye then return 0 end
|
||||
return math.atan2(eye[1] - x, eye[3] - z)
|
||||
end
|
||||
|
||||
-- Stand a `w` x `h` card with its feet centred on world (x, y, z).
|
||||
function BattleBillboard.matrix(x, y, z, w, h, yaw)
|
||||
return Mat4.mul(Mat4.mul(Mat4.translate(x, y, z), Mat4.rotateY(yaw)),
|
||||
Mat4.scale(w, h, 1))
|
||||
end
|
||||
|
||||
-- The world size a pic of `pw` x `ph` texture pixels stands at.
|
||||
function BattleBillboard.sizeFor(pw, ph)
|
||||
if not (pw and ph and pw > 0 and ph > 0) then return 0, 0 end
|
||||
local scale = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
|
||||
return pw * scale, ph * scale
|
||||
end
|
||||
|
||||
-- Draw one mon. `tex` is the pic already rendered to a texture (see
|
||||
-- OverworldBattle, which lets the engine's own battler draw produce it, so
|
||||
-- every faint slide, blink and squish comes along), `grow` the send-out
|
||||
-- animation's scale or nil.
|
||||
function BattleBillboard.draw(tex, x, y, z, grow)
|
||||
local mesh = unitQuad()
|
||||
if not (mesh and tex) then return false end
|
||||
local pw, ph = tex:getDimensions()
|
||||
local w, h = BattleBillboard.sizeFor(pw, ph)
|
||||
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
|
||||
|
||||
-- The same card, as the SUN sees it: no camera-ward pull (that is a trick
|
||||
-- for the view's own depth buffer and would drag the shadow off its owner)
|
||||
-- and no draw call of its own, because the shadow pass has its own.
|
||||
function BattleBillboard.caster(shadowMap, tex, x, y, z, grow)
|
||||
local mesh = unitQuad()
|
||||
if not (mesh and tex) then return false end
|
||||
local pw, ph = tex:getDimensions()
|
||||
local w, h = BattleBillboard.sizeFor(pw, ph)
|
||||
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)
|
||||
shadowMap.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw))
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleBillboard.invalidate()
|
||||
quad = nil
|
||||
end
|
||||
|
||||
return BattleBillboard
|
||||
@@ -0,0 +1,193 @@
|
||||
-- Overworld battles: the over-the-shoulder camera and its parallax drift.
|
||||
--
|
||||
-- The two mons are PINNED to their cells: each pic is drawn wherever its
|
||||
-- patch of ground projects to, not at a fixed screen slot. So the camera is
|
||||
-- not decoration -- it is the thing that decides where the fight appears,
|
||||
-- and it has to put those two patches of ground exactly where the battle
|
||||
-- screen wants its two pics:
|
||||
--
|
||||
-- the player's mon (26, 96) back pic, feet on the text box, well left
|
||||
-- the enemy's mon (124, 56) front pic, bottom of the 7x7 slot
|
||||
--
|
||||
-- Four screen coordinates, so four equations. The rig below is the solution:
|
||||
-- SIDE / BACK / HEIGHT place the eye relative to the arena's midpoint, LOOK
|
||||
-- aims it, and FRAME_H sets the lens, and together they land both marks
|
||||
-- within a thousandth of a pixel of the targets. They are not hand-picked
|
||||
-- numbers that looked about right -- they came out of a solver, and the
|
||||
-- suite reprojects them so a future edit either still lands or says so.
|
||||
--
|
||||
-- East is what decides which mon is on which side. The arena axis runs north
|
||||
-- (the enemy) to south (the player's mon), and a camera east of that axis
|
||||
-- sees the near end swing LEFT and the far end RIGHT -- the layout arrived
|
||||
-- at by standing in the right place rather than by mirroring anything.
|
||||
--
|
||||
-- ------- and two more equations, from the pixels
|
||||
--
|
||||
-- The pics are pixel art and their size on screen is not something the mod
|
||||
-- gets to choose: 56 pixels for a front pic, 64 for a back one. And a mon has
|
||||
-- to stand in ONE OVERWORLD SQUARE, or it towers over the houses and gives
|
||||
-- away that the world behind it is a picture. Together those say the square
|
||||
-- each mon stands on must project to about the width of its own pic, which is
|
||||
-- two more equations for the same six unknowns -- and they are what set the
|
||||
-- distance.
|
||||
--
|
||||
-- The answer is a LONG LENS FROM A LOW STANCE: twelve degrees above the
|
||||
-- floor, twelve degrees wide, from five blocks back. Not a stylistic choice
|
||||
-- -- it is what a 56-pixel sprite standing on a 16-pixel tile forces on a
|
||||
-- 160-pixel screen. Roughly three tiles fit across the frame, so the camera
|
||||
-- has to be far away and zoomed in rather than near and wide. That is the
|
||||
-- DEFAULT rig, and every map that can take it gets it.
|
||||
--
|
||||
-- ------- the exception: rooms too small to stand back from
|
||||
--
|
||||
-- Five blocks back is further than some rooms are wide. A gym is about ten
|
||||
-- cells across, so on one the eye lands OUTSIDE the map, where the border
|
||||
-- ring the engine draws round every map -- extruded into a cliff by this mode
|
||||
-- -- crosses the near Pokemon wherever it stands. Three gyms could not be
|
||||
-- staged anywhere at all for that reason.
|
||||
--
|
||||
-- So there is a second rig, and an arena asks for it by name (cam = "wide"
|
||||
-- in data/battle_arenas.lua). It comes in to about four cells with the lens
|
||||
-- opened up to match: an ordinary 44-degree shot that fits inside the room.
|
||||
-- The mons render smaller for it -- a bit over half a tile rather than a
|
||||
-- whole one -- which is the price. Both rigs are solved against the SAME four
|
||||
-- anchors, so the composition is identical either way; only the lens and the
|
||||
-- distance differ, which is what makes it safe to pick per map.
|
||||
--
|
||||
-- Rooms too small for the long lens are the reason it exists, but it is not
|
||||
-- only for them: an area that simply reads better with more of itself in
|
||||
-- shot can ask for it too.
|
||||
--
|
||||
-- Purely presentational, like everything else in this mod: the camera looks
|
||||
-- at the map, and nothing it does reaches collision, movement or scripts.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local BattleCam = {}
|
||||
|
||||
-- ------- the rig, in world pixels (a map cell is 16, a block 32)
|
||||
--
|
||||
-- Solved against the four anchors and two spans above, with the two mons 48
|
||||
-- world pixels (three cells) apart -- BattleArena.SHAPES is where that gap is
|
||||
-- set, and changing it invalidates these.
|
||||
|
||||
-- `frameH` is how much world the frame is tall enough to hold at the aim
|
||||
-- distance, which together with that distance is the lens.
|
||||
-- Named for the LENS, because that is what an author is choosing between
|
||||
-- when they look at a shot and decide it wants more room in it.
|
||||
BattleCam.RIGS = {
|
||||
-- the default: a long 11.5-degree lens from five blocks back, which is
|
||||
-- what makes one tile big enough to stand a 56-pixel mon on
|
||||
tele = {
|
||||
side = 78.79, back = 144.96, height = 37.88,
|
||||
lookX = -0.26, lookY = 0.34, frameH = 34.11,
|
||||
},
|
||||
-- 44 degrees from four cells: fits inside a room the long lens cannot
|
||||
-- stand back from, and shows more of anywhere else, at the cost of a
|
||||
-- smaller pair
|
||||
wide = {
|
||||
side = 41.98, back = 41.16, height = 28.48,
|
||||
lookX = -3.24, lookY = -1.35, frameH = 55.62,
|
||||
},
|
||||
}
|
||||
|
||||
BattleCam.DEFAULT_RIG = "tele"
|
||||
|
||||
-- The rig an arena asks for, falling back to the default for anything that
|
||||
-- does not ask (and for a name that is not one of the two).
|
||||
function BattleCam.rigFor(arena)
|
||||
local want = arena and arena.cam
|
||||
return BattleCam.RIGS[want] or BattleCam.RIGS[BattleCam.DEFAULT_RIG]
|
||||
end
|
||||
|
||||
-- ------- the drift
|
||||
--
|
||||
-- A slow orbit about the arena's vertical axis. Rotating about a point
|
||||
-- BETWEEN the two mons is what makes it parallax rather than a pan: the mons
|
||||
-- are pinned to the ground, so the near one slides one way across the frame
|
||||
-- and the far one slides the OTHER, by the amount their difference in
|
||||
-- distance implies. Over a full swing that is about eight pixels of relative
|
||||
-- movement -- plainly visible as depth, far too slow to fight the fight.
|
||||
-- The angle is small because the lens is long: two degrees of orbit is seven
|
||||
-- pixels of travel through an eleven-degree field of view.
|
||||
--
|
||||
-- Under it, a much smaller breath in and out along the same line, on an
|
||||
-- unrelated period, so the pair never returns to the same pose on any cycle
|
||||
-- a battle is long enough to show. A DOLLY rather than a pan of the aim:
|
||||
-- moving the aim point would slide both mons the same way, which with pinned
|
||||
-- pics is just the whole picture walking sideways. Changing the DISTANCE
|
||||
-- moves them apart and back together about the frame's centre, which is the
|
||||
-- same depth cue the orbit gives, from the other axis.
|
||||
BattleCam.PAN_YAW = math.rad(2) -- half-angle of the orbit
|
||||
BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
|
||||
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
|
||||
BattleCam.DOLLY_PERIOD = 37
|
||||
|
||||
BattleCam.t = 0
|
||||
|
||||
function BattleCam.reset()
|
||||
BattleCam.t = 0
|
||||
end
|
||||
|
||||
-- Real frame time, like every other presentational tween in this mod: a
|
||||
-- fast-forwarded battle must not spin the camera.
|
||||
function BattleCam.update(dt)
|
||||
BattleCam.t = BattleCam.t + (dt or 0)
|
||||
-- keep the phase small forever rather than letting a long session lose
|
||||
-- float precision in the sines below
|
||||
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
|
||||
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
|
||||
end
|
||||
|
||||
local function phase(t, period)
|
||||
return math.sin(2 * math.pi * t / period)
|
||||
end
|
||||
|
||||
-- The camera for `arena` this instant: the record Voxel3D.camera takes, plus
|
||||
-- the pitch the pull and the sun frustum want (measured from straight down,
|
||||
-- the same convention Voxel.angle uses).
|
||||
--
|
||||
-- `fov` here frames the GB's 160x144. A caller rendering at window
|
||||
-- resolution widens it for the extra picture around that frame -- see
|
||||
-- BattleScene.letterboxFov, which is what keeps the pins exact at any window
|
||||
-- size.
|
||||
--
|
||||
-- `groundY` is the height of the arena floor, so a fight staged on a ledge
|
||||
-- or a raised walkway is shot from above THAT rather than from inside it.
|
||||
function BattleCam.rig(arena, groundY)
|
||||
groundY = groundY or 0
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
|
||||
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
-- the breath scales the whole offset, height included, so the eye moves
|
||||
-- along its own line to the arena and the pitch of the shot never changes
|
||||
local k = 1 + BattleCam.PAN_DOLLY
|
||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||
local dx = (R.side * c - R.back * s) * k
|
||||
local dz = (R.side * s + R.back * c) * k
|
||||
|
||||
local eye = { mx + dx, groundY + R.height * k, mz + dz }
|
||||
local focus = { mx + R.lookX, groundY + R.lookY, mz }
|
||||
|
||||
local ex = eye[1] - focus[1]
|
||||
local ey = eye[2] - focus[2]
|
||||
local ez = eye[3] - focus[3]
|
||||
local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
|
||||
local horiz = math.sqrt(ex * ex + ez * ez)
|
||||
|
||||
return {
|
||||
eye = eye,
|
||||
focus = focus,
|
||||
fov = 2 * math.atan((R.frameH / 2) / dist),
|
||||
-- the world curve is a free-roam flourish that bends the horizon away
|
||||
-- from the player; a fixed camera on a staged shot has no player to bend
|
||||
-- around, and the bend would tip the arena floor out from under the mons
|
||||
-- the pics are pinned to
|
||||
curve = 0,
|
||||
}, math.atan2(horiz, math.max(1e-3, ey))
|
||||
end
|
||||
|
||||
return BattleCam
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Overworld battles: the depth-of-field pass over the arena.
|
||||
--
|
||||
-- A photographic lens focused on two subjects three cells apart holds a
|
||||
-- narrow slab of the world sharp and loses everything in front of and
|
||||
-- behind it. On this shot that slab is a BAND ACROSS THE FRAME, because the
|
||||
-- camera is fixed and looking down at a floor: distance from the lens runs
|
||||
-- monotonically up the picture, so screen height IS depth, and a band of
|
||||
-- rows is a slab of world. The floor the mons stand on stays sharp, the
|
||||
-- middle distance and the horizon soften, and the foreground the camera is
|
||||
-- leaning over softens the other way.
|
||||
--
|
||||
-- BOTH MONS ARE IN FOCUS, and not by tuning: they are drawn as the battle
|
||||
-- screen's own pics AFTER this runs, so they are never blurred at all. This
|
||||
-- pass only ever touches the world behind them -- which is exactly the
|
||||
-- separation a real shot of two subjects at the same distance would have.
|
||||
--
|
||||
-- Two separable gaussian passes over a 160x144 image: two full-frame draws
|
||||
-- of 23,040 pixels, which is nothing, and it buys the one cue that stops a
|
||||
-- 3D backdrop reading as wallpaper.
|
||||
|
||||
local BattleDOF = {}
|
||||
|
||||
-- Switched off for now. The pass is kept whole -- band maths, shader,
|
||||
-- canvases -- because the reason to have it has not gone away: it is the one
|
||||
-- cue that separates the pair from the ground behind them. It is off because
|
||||
-- the mons are geometry in the scene now rather than pics over it, and a blur
|
||||
-- that softens their own tile softens THEM, which the pinned-pic version
|
||||
-- never had to answer for. Turning it back on means solving that first.
|
||||
BattleDOF.ENABLED = false
|
||||
|
||||
-- Fallbacks for the band, in canvas uv. The caller normally measures it off
|
||||
-- the two ground marks -- the whole point is that the slab in focus is the
|
||||
-- one the mons are standing in -- and these are what a caller that cannot
|
||||
-- gets instead.
|
||||
BattleDOF.FOCUS_Y = 0.52
|
||||
BattleDOF.BAND = 0.16
|
||||
BattleDOF.RANGE = 0.32
|
||||
|
||||
-- How much floor either side of the two marks stays sharp, as a fraction of
|
||||
-- the gap between them: a mon is taller than the patch it stands on, and the
|
||||
-- ground just in front of and behind it belongs to the same slab.
|
||||
BattleDOF.BAND_MARGIN = 0.55
|
||||
|
||||
-- How far past the band it takes to reach full blur, in the same units as
|
||||
-- the band itself.
|
||||
BattleDOF.RANGE_SCALE = 2.0
|
||||
|
||||
-- Tap spacing at full blur, as a fraction of the canvas height, so the blur
|
||||
-- is the same depth of field in a window and fullscreen. The gaussian's
|
||||
-- reach is four taps and the two passes compound, so a little goes a long
|
||||
-- way.
|
||||
BattleDOF.SPACING = 0.0095
|
||||
|
||||
-- A touch of saturation on the way out, the same trick the tilt-shift pass
|
||||
-- uses: a blurred background reads as further away when it is also a
|
||||
-- little richer than the sharp subject in front of it.
|
||||
BattleDOF.SATURATION = 1.12
|
||||
|
||||
local SHADER = [[
|
||||
uniform vec2 dir; // one texel step along the axis being blurred
|
||||
uniform float focusY;
|
||||
uniform float band;
|
||||
uniform float range;
|
||||
uniform float spacing;
|
||||
uniform float boost; // 0 = plain blur pass, 1 = final pass (colour pop)
|
||||
uniform float saturation;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float d = abs(tc.y - focusY) - band;
|
||||
float s = clamp(d / range, 0.0, 1.0);
|
||||
s = s * s; // ease in, so the band edge has no visible seam
|
||||
vec2 o = dir * (s * spacing);
|
||||
vec4 sum = Texel(tex, tc) * 0.2270270270;
|
||||
sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946;
|
||||
sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216;
|
||||
sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541;
|
||||
sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162;
|
||||
if (boost > 0.5) {
|
||||
float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114));
|
||||
sum.rgb = mix(vec3(luma), sum.rgb, saturation);
|
||||
}
|
||||
return sum * color;
|
||||
}
|
||||
]]
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
local ping, pong, cw, ch = nil, nil, 0, 0
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER)
|
||||
shader = (ok and sh) or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
-- Its own pair of canvases rather than the tilt-shift pass's: those are
|
||||
-- sized to the window and this is sized to the GB frame, and sharing them
|
||||
-- would reallocate both every time a battle started or ended.
|
||||
local function getCanvases(w, h)
|
||||
if not ping or cw ~= w or ch ~= h then
|
||||
local ok, a = pcall(love.graphics.newCanvas, w, h)
|
||||
if not ok then return nil end
|
||||
local okB, b = pcall(love.graphics.newCanvas, w, h)
|
||||
if not okB then return nil end
|
||||
-- the gaussian's fractional tap offsets need linear filtering
|
||||
a:setFilter("linear", "linear")
|
||||
b:setFilter("linear", "linear")
|
||||
ping, pong, cw, ch = a, b, w, h
|
||||
end
|
||||
return ping, pong
|
||||
end
|
||||
|
||||
-- The sharp band for a shot whose two ground marks land at canvas rows
|
||||
-- `y1` and `y2`, as (focusY, band, range) in uv. This is the depth of field
|
||||
-- proper: the band is the slab of world the two mons occupy, and everything
|
||||
-- nearer or further softens away from it.
|
||||
function BattleDOF.bandFor(y1, y2, h)
|
||||
if not (y1 and y2 and h and h > 0) then
|
||||
return BattleDOF.FOCUS_Y, BattleDOF.BAND, BattleDOF.RANGE
|
||||
end
|
||||
local mid = (y1 + y2) / 2 / h
|
||||
local half = math.abs(y1 - y2) / 2 / h
|
||||
local band = half * (1 + BattleDOF.BAND_MARGIN)
|
||||
return math.min(1, math.max(0, mid)),
|
||||
band,
|
||||
math.max(1e-3, band * BattleDOF.RANGE_SCALE)
|
||||
end
|
||||
|
||||
-- Run the pass over `canvas` and return the result, or the input unchanged
|
||||
-- when it cannot run (headless, no shader support) -- so the caller always
|
||||
-- has something to composite. `focusY`, `band` and `range` are in canvas uv;
|
||||
-- omit them for the fixed fallback band.
|
||||
function BattleDOF.apply(canvas, focusY, band, range)
|
||||
if not (canvas and BattleDOF.ENABLED) then return canvas end
|
||||
local sh = getShader()
|
||||
if not sh then return canvas end
|
||||
local w, h = canvas:getDimensions()
|
||||
local a, b = getCanvases(w, h)
|
||||
if not a then return canvas end
|
||||
focusY = focusY or BattleDOF.FOCUS_Y
|
||||
band = band or BattleDOF.BAND
|
||||
range = range or BattleDOF.RANGE
|
||||
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
|
||||
-- the scene canvas filters nearest for its 1:1 blit; the taps need linear,
|
||||
-- restored below so the composite sees what it expects
|
||||
canvas:setFilter("linear", "linear")
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- replace, not alpha-blend: these are image-processing copies
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
pcall(sh.send, sh, "focusY", focusY)
|
||||
pcall(sh.send, sh, "band", band)
|
||||
pcall(sh.send, sh, "range", range)
|
||||
pcall(sh.send, sh, "spacing", math.max(0.75, h * BattleDOF.SPACING))
|
||||
pcall(sh.send, sh, "saturation", BattleDOF.SATURATION)
|
||||
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(a)
|
||||
pcall(sh.send, sh, "dir", { 1 / w, 0 })
|
||||
pcall(sh.send, sh, "boost", 0)
|
||||
love.graphics.draw(canvas)
|
||||
love.graphics.setCanvas(b)
|
||||
pcall(sh.send, sh, "dir", { 0, 1 / h })
|
||||
pcall(sh.send, sh, "boost", 1)
|
||||
love.graphics.draw(a)
|
||||
end)
|
||||
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
canvas:setFilter("nearest", "nearest")
|
||||
return ok and b or canvas
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function BattleDOF.invalidate()
|
||||
ping, pong, cw, ch = nil, nil, 0, 0
|
||||
end
|
||||
|
||||
return BattleDOF
|
||||
@@ -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
|
||||
@@ -0,0 +1,408 @@
|
||||
-- Overworld battles: the HUD's footing on a world that is not white.
|
||||
--
|
||||
-- Gen 1 draws its battle HUDs as black glyphs and bar tiles straight onto
|
||||
-- the white field, with no box around them -- the field IS the backing. Take
|
||||
-- the field away and put a route underneath and the name, the level and the
|
||||
-- HP numbers are black on grass, which is not readable.
|
||||
--
|
||||
-- So each HUD block gets a panel: the world behind it, blurred to frosted
|
||||
-- glass and laid back down translucent, with a tint that pushes it away from
|
||||
-- whatever colour the text is about to be. Frosted rather than opaque
|
||||
-- because the point of the mode is that you can see where you are standing,
|
||||
-- and an opaque slab in the corner of the frame is the white field back
|
||||
-- again by another name.
|
||||
--
|
||||
-- And the text flips. A panel over a sunlit meadow is bright and wants black
|
||||
-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
|
||||
-- white ones. So the panel's average brightness is measured and the glyphs
|
||||
-- follow it, with hysteresis so a slow camera drift across the threshold
|
||||
-- cannot strobe them.
|
||||
--
|
||||
-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
|
||||
-- a few times a second rather than every frame. The camera drifts at about
|
||||
-- a pixel a second; brightness cannot outrun that.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local BattleHud = {}
|
||||
|
||||
-- How solid the frost is over the world behind it, and how far the tint
|
||||
-- pushes it toward the far end from the text.
|
||||
--
|
||||
-- Both deliberately light. The panel is there to make glyphs legible, not to
|
||||
-- put a slab in the corner of the frame: at these values the sharp world
|
||||
-- still reads through it and the blur registers as a pane of glass rather
|
||||
-- than as a second background.
|
||||
BattleHud.FROST = 0.55
|
||||
BattleHud.TINT = 0.26
|
||||
|
||||
-- The luminance the glyphs flip at, with a dead band so a drift across it
|
||||
-- settles rather than strobes.
|
||||
BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs
|
||||
BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones
|
||||
|
||||
-- Frames between brightness readbacks.
|
||||
BattleHud.SAMPLE_EVERY = 12
|
||||
|
||||
-- The frost buffer's height; width follows the source's aspect. Small on
|
||||
-- purpose: the downscale is most of the blur, and what is read back for the
|
||||
-- brightness is one pixel of it.
|
||||
BattleHud.FROST_H = 72
|
||||
|
||||
local frost, frostW, frostH = nil, 0, 0
|
||||
local blurA, blurB = nil, nil
|
||||
local probe = nil
|
||||
local frame = 0
|
||||
local luma = {} -- panel key -> { value, dark, at }
|
||||
|
||||
local SHADER = [[
|
||||
uniform vec2 dir;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 sum = Texel(tex, tc) * 0.2270270270;
|
||||
sum += (Texel(tex, tc + dir) + Texel(tex, tc - dir)) * 0.1945945946;
|
||||
sum += (Texel(tex, tc + 2.0 * dir) + Texel(tex, tc - 2.0 * dir)) * 0.1216216216;
|
||||
sum += (Texel(tex, tc + 3.0 * dir) + Texel(tex, tc - 3.0 * dir)) * 0.0540540541;
|
||||
sum += (Texel(tex, tc + 4.0 * dir) + Texel(tex, tc - 4.0 * dir)) * 0.0162162162;
|
||||
return sum * color;
|
||||
}
|
||||
]]
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER)
|
||||
shader = (ok and sh) or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
local function canvasOf(w, h, filter)
|
||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||
if not ok then return nil end
|
||||
c:setFilter(filter or "linear", filter or "linear")
|
||||
return c
|
||||
end
|
||||
|
||||
-- Build (or rebuild) the frosted copy of `src` for this frame.
|
||||
--
|
||||
-- Two steps, because one is not enough: the downscale to a 72-row buffer
|
||||
-- averages the world down to something that no longer reads as terrain, and
|
||||
-- the separable gaussian over that turns the remaining structure into
|
||||
-- frosted glass rather than a mosaic of the tiles it came from.
|
||||
function BattleHud.build(src)
|
||||
if not src then return nil end
|
||||
local blur = getShader()
|
||||
local sw, sh = src:getDimensions()
|
||||
if sw <= 0 or sh <= 0 then return nil end
|
||||
local h = BattleHud.FROST_H
|
||||
local w = math.max(1, math.floor(sw * h / sh + 0.5))
|
||||
if not frost or frostW ~= w or frostH ~= h then
|
||||
frost = canvasOf(w, h)
|
||||
blurA = canvasOf(w, h)
|
||||
blurB = canvasOf(w, h)
|
||||
probe = probe or canvasOf(1, 1)
|
||||
if not (frost and blurA and blurB) then
|
||||
frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
|
||||
return nil
|
||||
end
|
||||
frostW, frostH = w, h
|
||||
end
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local prevFilter = { src:getFilter() }
|
||||
src:setFilter("linear", "linear")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(frost)
|
||||
love.graphics.draw(src, 0, 0, 0, w / sw, h / sh)
|
||||
if blur then
|
||||
love.graphics.setShader(blur)
|
||||
love.graphics.setCanvas(blurA)
|
||||
pcall(blur.send, blur, "dir", { 2.5 / w, 0 })
|
||||
love.graphics.draw(frost)
|
||||
love.graphics.setCanvas(blurB)
|
||||
pcall(blur.send, blur, "dir", { 0, 2.5 / h })
|
||||
love.graphics.draw(blurA)
|
||||
love.graphics.setShader()
|
||||
frost, blurB = blurB, frost -- the blurred one is the frost now
|
||||
end
|
||||
end)
|
||||
|
||||
love.graphics.setShader()
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
src:setFilter(prevFilter[1] or "nearest", prevFilter[2] or "nearest")
|
||||
frame = frame + 1
|
||||
return ok and frost or nil
|
||||
end
|
||||
|
||||
function BattleHud.frame()
|
||||
return frame
|
||||
end
|
||||
|
||||
-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
|
||||
--
|
||||
-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
|
||||
-- canvas, which IS the mean, and that one pixel is read back. Cached for
|
||||
-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
|
||||
-- nothing it measures moves faster than that.
|
||||
local function sampleLuma(key, fx, fy, fw, fh)
|
||||
local hit = luma[key]
|
||||
if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
|
||||
if not (frost and probe and frostW > 0) then return hit and hit.value end
|
||||
if fw <= 0 or fh <= 0 then return hit and hit.value end
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local value = hit and hit.value or 1
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(probe)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
||||
love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
|
||||
love.graphics.setCanvas()
|
||||
local data = probe:newImageData()
|
||||
local r, g, b = data:getPixel(0, 0)
|
||||
if data.release then pcall(data.release, data) end
|
||||
value = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
end)
|
||||
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
if not ok then return hit and hit.value end
|
||||
|
||||
luma[key] = { value = value, at = frame }
|
||||
return value
|
||||
end
|
||||
|
||||
-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
|
||||
-- in the source the frost was built from.
|
||||
local function frostRect(rect, box)
|
||||
local kx = frostW / box.pw
|
||||
local ky = frostH / box.ph
|
||||
local fx = (box.lx + rect[1] * box.scale) * kx
|
||||
local fy = (box.ly + rect[2] * box.scale) * ky
|
||||
local fw = rect[3] * box.scale * kx
|
||||
local fh = rect[4] * box.scale * ky
|
||||
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
|
||||
-- single pass and there is only one glyph colour to be had out of it -- and
|
||||
-- a frame with a black-lettered HUD in one corner and a white-lettered one
|
||||
-- in the other would read as a bug rather than as adaptation. The DARKER
|
||||
-- panel decides, because it is the one that cannot afford to be wrong, and
|
||||
-- the tint below then commits both panels to that reading.
|
||||
local wasDark = false
|
||||
|
||||
function BattleHud.verdict(rects, box, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local toFrost = mapper(world)
|
||||
local darkest = nil
|
||||
for key, rect in pairs(rects) do
|
||||
local fx, fy, fw, fh = toFrost(rect, box)
|
||||
local v = sampleLuma(key, fx, fy, fw, fh)
|
||||
if v and (not darkest or v < darkest) then darkest = v end
|
||||
end
|
||||
if not darkest then return wasDark end
|
||||
-- hysteresis: it takes a clear move past the far threshold to flip back,
|
||||
-- so a camera drifting across the boundary settles instead of strobing
|
||||
if wasDark then
|
||||
wasDark = darkest < BattleHud.DARK_LEAVE
|
||||
else
|
||||
wasDark = darkest < BattleHud.DARK_ENTER
|
||||
end
|
||||
return wasDark
|
||||
end
|
||||
|
||||
-- Draw one HUD panel into the current target, in that target's own
|
||||
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
|
||||
-- for a panel laid straight onto the world image.
|
||||
--
|
||||
-- The tint always pushes AWAY from the glyph colour that is about to be
|
||||
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
|
||||
-- gets darker under white text, a bright one brighter under black text.
|
||||
function BattleHud.panel(rect, box, dark, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local fx, fy, fw, fh = mapper(world)(rect, box)
|
||||
local ok = pcall(function()
|
||||
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
||||
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
|
||||
love.graphics.draw(frost, quad, rect[1], rect[2], 0,
|
||||
rect[3] / fw, rect[4] / fh)
|
||||
local shade = dark and 0 or 1
|
||||
love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
|
||||
love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ------- flipping the glyphs
|
||||
--
|
||||
-- Over a dark panel the HUD's black text has to go white, and it cannot be
|
||||
-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
|
||||
-- times white is still black. The colour channel has to be REPLACED.
|
||||
--
|
||||
-- So the HUD is drawn into a scratch layer and that layer is composited back
|
||||
-- through a shader that whitens whatever is nearly black and leaves the rest
|
||||
-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
|
||||
-- everything the HUD draws as ink -- while the HP bar's own greens and reds
|
||||
-- are well clear of the threshold and come through untouched.
|
||||
--
|
||||
-- Composited back into whatever the caller had bound, which is what makes it
|
||||
-- work in both pipelines without knowing which one it is in: in the colorized
|
||||
-- one that target is the grayscale BG canvas, where white IS shade 0 and the
|
||||
-- zone pass then colours the flipped glyphs like every other lightest-shade
|
||||
-- surface; in the flat fallback it is the screen, where white is white.
|
||||
local INK = 0.35 -- luminance at or under which a pixel counts as ink
|
||||
|
||||
local FLIP = [[
|
||||
uniform float ink;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
|
||||
if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
|
||||
return p * color;
|
||||
}
|
||||
]]
|
||||
|
||||
local flipShader = nil
|
||||
local layer = nil
|
||||
|
||||
local function getFlip()
|
||||
if flipShader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, FLIP)
|
||||
flipShader = (ok and sh) or false
|
||||
end
|
||||
return flipShader or nil
|
||||
end
|
||||
|
||||
-- Whether the flip pass can run at all, for the shot driver's log.
|
||||
function BattleHud.flipReady()
|
||||
return getFlip() ~= nil
|
||||
end
|
||||
|
||||
-- Run `fn` with its ink whitened. Falls back to running it plainly when the
|
||||
-- scratch layer or the shader is unavailable, so a driver that cannot do
|
||||
-- either gets the vanilla black HUD rather than no HUD.
|
||||
function BattleHud.flipGlyphs(w, h, fn)
|
||||
local sh = getFlip()
|
||||
if not sh then return fn() end
|
||||
if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
|
||||
layer = canvasOf(w, h, "nearest")
|
||||
if not layer then return fn() end
|
||||
end
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local ok, err = pcall(function()
|
||||
love.graphics.setCanvas(layer)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
fn()
|
||||
end)
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
if not ok then error(err, 0) end
|
||||
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "ink", INK)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(layer, 0, 0)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- ------- the whole HUD layer as a texture
|
||||
--
|
||||
-- The two blocks do not sit in the same place any more: each is snapped to its
|
||||
-- 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
|
||||
for _, hit in pairs(luma) do
|
||||
if not best or hit.value < best then best = hit.value end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
function BattleHud.invalidate()
|
||||
frost, blurA, blurB, probe = nil, nil, nil, nil
|
||||
frostW, frostH = 0, 0
|
||||
luma = {}
|
||||
wasDark = false
|
||||
layer, hudLayer = nil, nil
|
||||
end
|
||||
|
||||
return BattleHud
|
||||
@@ -0,0 +1,251 @@
|
||||
-- Overworld battles: giving a battle pic its paper back.
|
||||
--
|
||||
-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
|
||||
-- engine's decoded PNGs key that shade to alpha 0 -- which was free, because
|
||||
-- the field behind them was white too. A transparent belly on a white page
|
||||
-- is a white belly.
|
||||
--
|
||||
-- Put a route behind it and the belly is grass. Charizard's chest, the whites
|
||||
-- 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. 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,
|
||||
-- forced-mono rebuild, shiny recolour, a mod's replacement art -- so this
|
||||
-- needs to know nothing about how any of that was arrived at. Once per pic
|
||||
-- per session, cached on the image itself.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local BattlePics = {}
|
||||
|
||||
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
|
||||
-- out of scope takes its filled twin with it rather than pinning a texture
|
||||
-- for the session.
|
||||
local cache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
-- What an enclosed hole is filled with. White, because white is what the
|
||||
-- battle field was: this restores the pixel the artist drew and the engine
|
||||
-- then keyed away, it does not invent a new one.
|
||||
BattlePics.FILL = { 1, 1, 1, 1 }
|
||||
|
||||
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
||||
local CUT = 0.5
|
||||
|
||||
-- Read the pixels the engine would actually blit. A LOVE Image does not hand
|
||||
-- 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.
|
||||
local function readBack(img)
|
||||
local w, h = img:getDimensions()
|
||||
if w <= 0 or h <= 0 then return nil end
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local data = nil
|
||||
local ok = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 0, 0)
|
||||
love.graphics.setCanvas()
|
||||
data = canvas:newImageData()
|
||||
if canvas.release then pcall(canvas.release, canvas) end
|
||||
end)
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
love.graphics.setColor(prevR or 1, prevG or 1, prevB or 1, prevA or 1)
|
||||
return ok and data or nil
|
||||
end
|
||||
|
||||
-- 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, 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 < x0 or y < y0 or x > x1 or y > y1 then return end
|
||||
local key = y * w + x
|
||||
if outside[key] then return end
|
||||
if not clear(x, y) then return end
|
||||
outside[key] = true
|
||||
top = top + 1
|
||||
stack[top] = key
|
||||
end
|
||||
for x = x0, x1 do push(x, y0) end
|
||||
for y = y0, y1 do
|
||||
push(x0, y)
|
||||
push(x1, y)
|
||||
end
|
||||
-- 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]
|
||||
top = top - 1
|
||||
local x, y = key % w, math.floor(key / w)
|
||||
push(x - 1, y)
|
||||
push(x + 1, y)
|
||||
push(x, y - 1)
|
||||
push(x, y + 1)
|
||||
end
|
||||
return outside
|
||||
end
|
||||
|
||||
-- The pic with its enclosed holes filled, or the pic itself when that could
|
||||
-- not be done (no pixel access, a driver that refused the readback). Never
|
||||
-- nil for a non-nil argument: a caller must always have something to draw.
|
||||
function BattlePics.filled(img)
|
||||
if not img then return img end
|
||||
local hit = cache[img]
|
||||
if hit ~= nil then return hit or img end
|
||||
|
||||
local made = nil
|
||||
local ok = pcall(function()
|
||||
local data = readBack(img)
|
||||
if not data then return end
|
||||
local w, h = data:getDimensions()
|
||||
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
|
||||
-- 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 = x0, x1 do
|
||||
if not outside[row + x] then
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a <= CUT then
|
||||
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- nothing enclosed: hand the original back rather than a copy of it
|
||||
if not changed then return end
|
||||
local out = love.graphics.newImage(data)
|
||||
out:setFilter("nearest", "nearest")
|
||||
made = out
|
||||
end)
|
||||
|
||||
cache[img] = (ok and made) or false
|
||||
return made or img
|
||||
end
|
||||
|
||||
function BattlePics.invalidate()
|
||||
cache = setmetatable({}, { __mode = "k" })
|
||||
end
|
||||
|
||||
return BattlePics
|
||||
@@ -0,0 +1,499 @@
|
||||
-- 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
|
||||
-- backdrop reaches the screen through Renderer's worldOverride, the seam a
|
||||
-- render pipeline's finished world image already composites through, which
|
||||
-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen
|
||||
-- then blits over it in the classic letterbox. So the world is as crisp as
|
||||
-- the free-roam diorama and the pics, HUDs and text box stay exactly the
|
||||
-- chunky GB art they are.
|
||||
--
|
||||
-- Rendering the whole window rather than just the letterbox means the
|
||||
-- framing has to be split in two. The RIG frames the GB's 160x144 (see
|
||||
-- BattleCam, which is solved against coordinates in that frame); this
|
||||
-- module widens the lens by exactly the ratio the window bears to the
|
||||
-- letterbox, so the letterbox sub-rectangle of what gets rendered is
|
||||
-- bit-for-bit the framing the rig asked for, and everything outside it is
|
||||
-- extra picture. That is what lets the two mons be PINNED: their cells
|
||||
-- project to the same GB coordinates at any window size or zoom.
|
||||
--
|
||||
-- Characters are deliberately absent. The overworld cast is culled for the
|
||||
-- length of the battle (see OverworldBattle), so this pass has terrain,
|
||||
-- grass and flowers and nothing that walks -- the arena is empty, which is
|
||||
-- what makes it an arena.
|
||||
--
|
||||
-- Everything expensive is shared with the free-roam mode rather than
|
||||
-- duplicated: the same chunk meshes out of ChunkMesher, the same palette
|
||||
-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a
|
||||
-- map already meshed for walking around costs the frame it draws and
|
||||
-- nothing else.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local BattleScene = {}
|
||||
|
||||
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
|
||||
-- is solved against.
|
||||
BattleScene.GB_W = 160
|
||||
BattleScene.GB_H = 144
|
||||
|
||||
-- A map cell in world pixels: the overworld square a mon stands on, which is
|
||||
-- both what the arena is measured in and what a mon is sized to.
|
||||
BattleScene.CELL = 16
|
||||
|
||||
-- How far into black a shadow goes in the arena, against the free-roam
|
||||
-- mode's own lighter setting.
|
||||
--
|
||||
-- Darker on purpose, and only here. Walking around, a shadow is scenery and
|
||||
-- wants to stay out of the way of reading the map. In a battle it is doing
|
||||
-- one specific job: the two mons are flat cards, and the ONLY thing telling
|
||||
-- the eye they are standing on that floor rather than hanging in front of it
|
||||
-- is the shadow they put on it. A faint one leaves them floating.
|
||||
BattleScene.SHADOW_ALPHA = 0.68
|
||||
|
||||
-- Which rung of the sky ramp an indoor void is painted with. A room has no
|
||||
-- sky, but it does have somewhere the geometry stops, and leaving that
|
||||
-- transparent would show the letterbox clear through the gaps.
|
||||
local INDOOR_SHADE = 4
|
||||
|
||||
-- ------- where the GB frame sits inside the window
|
||||
--
|
||||
-- Renderer blits worldOverride one canvas pixel to one screen pixel and then
|
||||
-- blits the 160x144 UI canvas into a centred, integer-scaled letterbox. So
|
||||
-- these have to agree with Renderer:endFrame exactly, or the pins land off
|
||||
-- the mons by however much they disagree.
|
||||
function BattleScene.letterbox()
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local pw, ph = BattleScene.pixelSize()
|
||||
local s = Renderer:fitScale()
|
||||
return math.floor((pw - BattleScene.GB_W * s) / 2),
|
||||
math.floor((ph - BattleScene.GB_H * s) / 2),
|
||||
s, pw, ph
|
||||
end
|
||||
|
||||
-- The window in FRAMEBUFFER pixels, which is what the override blit works
|
||||
-- in. love.graphics.getDimensions is in LOVE units and differs from this by
|
||||
-- the display density on mobile.
|
||||
function BattleScene.pixelSize()
|
||||
if love.graphics.getPixelDimensions then
|
||||
local pw, ph = love.graphics.getPixelDimensions()
|
||||
if pw and ph and pw > 0 and ph > 0 then return pw, ph end
|
||||
end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
-- Widen the rig's vertical field of view from the GB frame to the whole
|
||||
-- window, so the letterbox rows show exactly what the rig framed.
|
||||
--
|
||||
-- The horizontal falls out of it: at aspect pw/ph the window's half-width is
|
||||
-- tan(fov/2) * pw/ph, and the letterbox is 160*s of those pw pixels, which
|
||||
-- works back out to the GB frame's own 160/144. So one scale on the vertical
|
||||
-- pins both axes.
|
||||
function BattleScene.letterboxFov(fovGB, ph, s)
|
||||
local span = BattleScene.GB_H * s
|
||||
if span <= 0 then return fovGB end
|
||||
return 2 * math.atan(math.tan(fovGB / 2) * ph / span)
|
||||
end
|
||||
|
||||
-- ------- palette
|
||||
--
|
||||
-- The world palette a map draws under, in the shape VoxelScene's colour
|
||||
-- helpers take. Rebuilt per frame from the overworld state, which is where
|
||||
-- the engine's own pipeline context gets it too (OverworldController's
|
||||
-- ctx.paletteFor).
|
||||
local function paletteFor(state, home)
|
||||
return function(map)
|
||||
return PaletteFX.pal(require("src.core.Game").data,
|
||||
state:paletteNameFor(map or home))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the map the fight is staged on
|
||||
--
|
||||
-- Normally the one the player is standing on. An authored arena may name
|
||||
-- another floor of the same cave or building (see BattleArena), and then the
|
||||
-- scene is THAT map: its terrain, its palette, its sky. Nothing else in the
|
||||
-- battle changes -- the fight, the party, the player's own position are all
|
||||
-- exactly where they were.
|
||||
--
|
||||
-- A foreign floor is meshed alone, with no connected neighbours: connections
|
||||
-- are the player's neighbourhood, and the map the camera has gone to visit is
|
||||
-- not standing in it. Both maps are kept live so neither the arena's mesh nor
|
||||
-- the one waiting to be walked back onto is evicted mid-battle.
|
||||
local function prefetchArena(state, host)
|
||||
if host == state.map then return VoxelScene.prefetch(state) end
|
||||
local live = { [host.id] = true, [state.map.id] = true }
|
||||
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
|
||||
ChunkMesher.setLive(live)
|
||||
TerrainAtlas.setLive(live)
|
||||
local terrain = ChunkMesher.request(host, false, nil, true)
|
||||
or ChunkMesher.peek(host, true)
|
||||
return terrain, {}
|
||||
end
|
||||
|
||||
-- ------- the sun
|
||||
--
|
||||
-- Only has to be drawn once per battle: the arena does not move, and neither
|
||||
-- does the light. So the signature is the map, the arena and the meshes --
|
||||
-- not the camera, which is the one thing that IS moving and the one thing
|
||||
-- the sun does not care about.
|
||||
-- ------- the two mons, hung on their cells
|
||||
--
|
||||
-- The billboard texture is the battle screen's own 160x144 pics layer with
|
||||
-- one side rendered into it (see OverworldBattle.sideTexture), so the quad is
|
||||
-- that whole frame stood up on the map -- which is what carries every pic
|
||||
-- effect the engine applies without any of them being reimplemented here.
|
||||
--
|
||||
-- Its size follows from one number: a full 7x7-tile mon covers one overworld
|
||||
-- square, so a canvas pixel is FULL_W / FULL_PIC world pixels and the card is
|
||||
-- the canvas at that scale. Its placement follows from the anchor the
|
||||
-- texture reports -- the column the pic was centred on and the row its feet
|
||||
-- were put on -- which is translated onto the cell before the card is stood
|
||||
-- up, so a mon of any size in any pose has its feet on the ground.
|
||||
-- `mirror` flips the card about its own anchor column. Both mons wear their
|
||||
-- FRONT pic, which is drawn facing out of the screen -- so dropped into the
|
||||
-- world unaltered the pair stand back to back, both looking the same way past
|
||||
-- each other. Mirroring the near one turns it to face the far one, which is
|
||||
-- what a fight looks like; and because it is a flip about the pic's own
|
||||
-- centre the feet do not move off the tile.
|
||||
--
|
||||
-- The player's TRAINER pic is the exception, and it is exempted below. That
|
||||
-- one is a BACK view -- the player seen from behind, already turned to face
|
||||
-- up the field -- so it arrives pointing the right way and mirroring it would
|
||||
-- turn it around to face the camera it is standing in front of.
|
||||
local function monMatrix(tex, x, groundY, z, mirror)
|
||||
local k = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
|
||||
local w = BattleScene.GB_W * k
|
||||
local h = BattleScene.GB_H * k
|
||||
local ox = -((tex.ax / BattleScene.GB_W) - 0.5) * w
|
||||
local oy = -((BattleScene.GB_H - tex.ay) / BattleScene.GB_H) * h
|
||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
||||
local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1))
|
||||
if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end
|
||||
return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
|
||||
card)
|
||||
end
|
||||
|
||||
-- Every mon that has something to show this frame, as (texture, matrix).
|
||||
local function monCards(arena, groundY, textures)
|
||||
local out = {}
|
||||
if not textures then return out end
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local tex = textures[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
if tex and tex.canvas and cell then
|
||||
local mirror = (side == "player") and not tex.trainer
|
||||
out[#out + 1] = { tex = tex.canvas,
|
||||
model = monMatrix(tex, cell[1], groundY, cell[2],
|
||||
mirror) }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
BattleScene.monCards = monCards
|
||||
|
||||
-- The sun has to see the mons too, or they stand on the ground without
|
||||
-- putting anything on it. They are the one thing in this scene that MOVES,
|
||||
-- so `token` -- a counter the caller bumps whenever a pic could have changed
|
||||
-- -- goes in the signature; the terrain half of the answer would otherwise
|
||||
-- keep a stale pass alive and freeze the shadows in whatever pose they were
|
||||
-- first drawn in.
|
||||
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),
|
||||
-- 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
|
||||
|
||||
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
atlasFor, cards, token, host, neighbors)
|
||||
if not ShadowMap.available() then return end
|
||||
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
if not ShadowMap.stale(sig) then return end
|
||||
if not ShadowMap.begin(cx, cy, vw, vh) then return end
|
||||
|
||||
ShadowMap.draw(terrain, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- 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),
|
||||
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,
|
||||
ShadowMap.snug(card.model))
|
||||
end
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
|
||||
-- The height of the arena floor: the ground the two mons stand on. Both
|
||||
-- cells are open, so they are normally the same; take the player's, which is
|
||||
-- the one nearer the camera and therefore the one a mismatch would show up
|
||||
-- against.
|
||||
function BattleScene.groundY(map, arena)
|
||||
local ok, h = pcall(VoxelScene.groundAt, map,
|
||||
arena.playerCell[1], arena.playerCell[2])
|
||||
return (ok and h) or 0
|
||||
end
|
||||
|
||||
-- Where a world point lands in GB frame coordinates under `vp`, or nil when
|
||||
-- it is behind the camera. This is the function the pins are built on: it
|
||||
-- takes the window-resolution clip position and divides the letterbox back
|
||||
-- out of it, so the answer is in the same 160x144 space the battle screen
|
||||
-- draws its pics in.
|
||||
function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph)
|
||||
local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4]
|
||||
local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8]
|
||||
local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16]
|
||||
if cw <= 1e-6 then return nil end
|
||||
-- viewProjection already flipped clip Y into LOVE's Y-down convention
|
||||
local px = (cx / cw * 0.5 + 0.5) * pw
|
||||
local py = (cy / cw * 0.5 + 0.5) * ph
|
||||
return (px - lx) / s, (py - ly) / s
|
||||
end
|
||||
|
||||
-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} },
|
||||
-- the two marks in GB coordinates -- or nil when there is nothing to draw
|
||||
-- yet (the terrain mesh is still building, the driver has no depth support).
|
||||
-- nil is not a failure: the caller simply leaves the battle screen as the
|
||||
-- engine drew it for that frame.
|
||||
-- White, for the hit flash, and how far toward it the card goes.
|
||||
--
|
||||
-- The shader replaces the card's colour rather than multiplying it, so at
|
||||
-- full strength this is the sprite turned into a solid white silhouette --
|
||||
-- which is what the effect is on a flat GB screen and far too much on a
|
||||
-- sprite standing in a lit world. Held well short of 1, the mon's own
|
||||
-- shading still reads through the flash: it looks struck rather than
|
||||
-- deleted.
|
||||
BattleScene.FLASH_COLOR = { 1, 1, 1 }
|
||||
BattleScene.FLASH_STRENGTH = 0.5
|
||||
|
||||
function BattleScene.render(state, arena, textures, token)
|
||||
if not (state and state.map and arena) then return nil end
|
||||
if not Voxel3D.available() then return nil end
|
||||
|
||||
-- the floor the fight is staged on: normally the player's own, sometimes
|
||||
-- another floor of the same cave or building (see BattleArena)
|
||||
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)
|
||||
if not terrain then return nil end
|
||||
|
||||
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
||||
if not (pw > 0 and ph > 0 and s > 0) then return nil end
|
||||
|
||||
local palette = paletteFor(state, host)
|
||||
local function atlasFor(map)
|
||||
return TerrainAtlas.forMap(map, VoxelScene._modeColors(palette, map))
|
||||
end
|
||||
|
||||
local groundY = BattleScene.groundY(host, arena)
|
||||
local cam, pitch = BattleCam.rig(arena, groundY)
|
||||
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
|
||||
|
||||
local cx, cy = arena.mid[1], arena.mid[2]
|
||||
-- the world extents the sun frustum is fitted to; the camera itself is
|
||||
-- framed by cam.fov, so these only have to describe the ground in shot
|
||||
local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s)
|
||||
local vw = vh * pw / ph
|
||||
|
||||
-- the cards need the camera's eye to face it, so the rig has to be live
|
||||
-- before they are built; Voxel3D.eye is set by viewProjection, which
|
||||
-- beginScene calls -- so a provisional one is taken here for the sun pass
|
||||
-- and the real one is rebuilt inside the scene below.
|
||||
Voxel3D.camera = cam
|
||||
Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
local cards = monCards(arena, groundY, textures)
|
||||
Voxel3D.camera = nil
|
||||
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
|
||||
cards, token, host, neighbors)
|
||||
|
||||
-- An opaque void either way. Outdoors the camera is low enough that the
|
||||
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
|
||||
-- of the same ramp, which is a room's "past the wall". Transparent -- the
|
||||
-- free-roam default -- would let the letterbox clear through wherever the
|
||||
-- geometry stops.
|
||||
local sky = VoxelScene.skyColor(host, 1)
|
||||
or VoxelScene.skyShade(INDOOR_SHADE, 1)
|
||||
|
||||
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 -- 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
|
||||
-- through the override so the player's own row is never written to.
|
||||
local gridWas = VoxelGrid.override
|
||||
VoxelGrid.override = true
|
||||
local out = nil
|
||||
local ok, err = pcall(function()
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
-- free-roam pass does too, but the two are alive at different moments
|
||||
-- and a shared slot would reallocate on every battle entry and exit
|
||||
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
|
||||
return
|
||||
end
|
||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- The mons, standing on their tiles. Depth-tested like everything else,
|
||||
-- so a ledge or a tree between the camera and a Pokemon really is in
|
||||
-- front of it, and the alpha discard cuts the sprite's own outline out of
|
||||
-- the card. A small camera-ward pull keeps a card rooted to the ground
|
||||
-- plane from z-fighting the tile it is standing on.
|
||||
-- The engine's hit flash is a full-screen white rectangle, which on a
|
||||
-- white battle field is a flash and over a world is a whiteout of the
|
||||
-- map, the HUD and the text box alike. It is dropped on the way past
|
||||
-- (see OverworldBattle) and put back HERE, on the two things it was ever
|
||||
-- about: the mons themselves go solid white for those frames.
|
||||
local flashing = textures and textures.flash
|
||||
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, 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
|
||||
-- orbit's -- there is no character here for them to overdraw, but the
|
||||
-- pull is also what keeps a tuft from z-fighting the floor it stands on
|
||||
local pull = VoxelScene.pull(math.max(pitch, 0.05))
|
||||
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
local canvas = Voxel3D.endScene()
|
||||
if not canvas then return end
|
||||
|
||||
local vp = Voxel3D.vp
|
||||
local pmx, pmy = BattleScene.toGB(vp, arena.player[1], groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local emx, emy = BattleScene.toGB(vp, arena.enemy[1], groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
if not (pmx and emx) then return end
|
||||
-- How wide one overworld square is on screen where each mon stands, in
|
||||
-- GB pixels. This is what the pics are scaled to: a mon covers its own
|
||||
-- square and no more, at whatever the drift has done to the distance.
|
||||
local half = BattleScene.CELL / 2
|
||||
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
if not (pl and pr and el and er) then return end
|
||||
out = {
|
||||
canvas = canvas,
|
||||
player = { pmx, pmy },
|
||||
enemy = { emx, emy },
|
||||
playerSpan = math.abs(pr - pl),
|
||||
enemySpan = math.abs(er - el),
|
||||
-- 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
|
||||
-- renders (the free-roam pipeline, next frame) must find the orbit back
|
||||
Voxel3D.camera = nil
|
||||
Voxel3D.SHADOW_ALPHA = sunWas
|
||||
VoxelGrid.override = gridWas
|
||||
if not ok then
|
||||
-- endScene never ran, so the canvas is still bound and the shader still
|
||||
-- set; put the frame back the way it was found before rethrowing
|
||||
pcall(love.graphics.setShader)
|
||||
pcall(love.graphics.setDepthMode)
|
||||
pcall(love.graphics.setCanvas)
|
||||
error(err, 0)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return BattleScene
|
||||
+55
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
+353
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+189
-17
@@ -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
|
||||
|
||||
+293
-8
@@ -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
|
||||
@@ -232,7 +274,15 @@ local SHADER = [[
|
||||
-- Each entry is nil = untried, false = unavailable.
|
||||
local shaders = { [false] = nil, [true] = nil }
|
||||
local activeShader = nil -- the variant this pass bound
|
||||
local canvas, canvasW, canvasH = nil, 0, 0
|
||||
|
||||
-- Scene canvases, one per NAMED SLOT. There are exactly two callers and
|
||||
-- they want different sizes -- the free-roam pass renders at the window's
|
||||
-- pixel dimensions, the overworld battle at the GB's 160x144 -- and a
|
||||
-- single cached canvas made every battle entry and exit reallocate one.
|
||||
-- A slot reallocates only when its OWN size changes, which is a window
|
||||
-- resize, so the pair is stable for a session.
|
||||
local slots = {}
|
||||
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
|
||||
local active = false
|
||||
|
||||
local IDENTITY = Mat4.identity()
|
||||
@@ -312,9 +362,47 @@ end
|
||||
|
||||
-- ---------------------------------------------------------------- camera --
|
||||
|
||||
-- An explicit camera, replacing the orbit below for as long as it is set:
|
||||
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
|
||||
--
|
||||
-- The orbit is the free-roam camera and it is described entirely by ONE
|
||||
-- number, the pitch, because that is all a camera following the player over
|
||||
-- their own map ever needs. A staged shot -- the overworld battle's
|
||||
-- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw,
|
||||
-- it does not sit above its focus, and its framing comes from the arena
|
||||
-- rather than from the view size. Rather than widen the orbit into
|
||||
-- something that could express both and be the wrong shape for each, a
|
||||
-- caller with a camera of its own simply hands it over.
|
||||
--
|
||||
-- Everything downstream is unchanged by this: the shader uniforms, project()
|
||||
-- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same
|
||||
-- way either way.
|
||||
Voxel3D.camera = nil
|
||||
|
||||
-- View and projection for a `vw` x `vh` world-pixel view centred on
|
||||
-- (cx, cy) in world pixels. Returns the combined matrix.
|
||||
function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
local cam = Voxel3D.camera
|
||||
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]
|
||||
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
|
||||
local proj = Mat4.perspective(cam.fov, vw / vh,
|
||||
math.max(1, dist * 0.05), dist * 4 + 4096)
|
||||
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
||||
-- bypass LOVE's transform_projection and canvas coordinates run Y down
|
||||
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
||||
-- world up, so the horizon stays level -- a placed camera that rolled
|
||||
-- with its own pitch would tip the whole arena
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
|
||||
end
|
||||
|
||||
local a = Voxel.angle
|
||||
local focal = Voxel.FOCAL
|
||||
local dist = focal * vh
|
||||
@@ -326,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.
|
||||
@@ -343,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
|
||||
@@ -351,7 +526,9 @@ end
|
||||
-- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the
|
||||
-- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the
|
||||
-- void transparent, which is what every rung below it wants.
|
||||
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
|
||||
-- `slot` names which cached canvas to render into (see `slots` above);
|
||||
-- omitted is the free-roam world pass.
|
||||
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- the wireframe variant when the player has it on AND it built; either
|
||||
-- answer falls through to the plain scene rather than to no scene
|
||||
local grid = VoxelGrid.enabled()
|
||||
@@ -360,12 +537,19 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
|
||||
grid, sh = false, Voxel3D.shader()
|
||||
end
|
||||
if not sh then return false end
|
||||
if not canvas or canvasW ~= w or canvasH ~= h then
|
||||
local name = slot or "world"
|
||||
local held = slots[name]
|
||||
if not (held and held.w == w and held.h == h) then
|
||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||
if not ok then return false end
|
||||
c:setFilter("nearest", "nearest")
|
||||
canvas, canvasW, canvasH = c, w, h
|
||||
if held and held.canvas and held.canvas.release then
|
||||
pcall(held.canvas.release, held.canvas)
|
||||
end
|
||||
held = { canvas = c, w = w, h = h }
|
||||
slots[name] = held
|
||||
end
|
||||
canvas, canvasW, canvasH = held.canvas, w, h
|
||||
-- a depth buffer is what makes occlusion real: walk behind a building and
|
||||
-- the building wins, with no y-sorting anywhere
|
||||
local ok = pcall(love.graphics.setCanvas,
|
||||
@@ -374,8 +558,23 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
|
||||
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
|
||||
@@ -386,7 +585,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
|
||||
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.
|
||||
@@ -410,9 +608,27 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
|
||||
-- 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
|
||||
Voxel3D.curveK = WorldCurve.k(vh)
|
||||
-- a fixed distance ahead of the player rather than sitting on the map.
|
||||
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
|
||||
local placed = Voxel3D.camera
|
||||
Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh)
|
||||
Voxel3D.curveX, Voxel3D.curveZ = cx, cy
|
||||
pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK })
|
||||
-- clip w at the focus point, the reference depth project() reports scale
|
||||
@@ -483,6 +699,65 @@ function Voxel3D.beginGhost()
|
||||
end
|
||||
end
|
||||
|
||||
-- Flatten whatever is drawn next to one solid colour, or nil to stop.
|
||||
--
|
||||
-- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted
|
||||
-- depth test and half alpha -- this is for something drawn normally that
|
||||
-- simply wants to come out one colour, which is what a hit flash on a sprite
|
||||
-- is. beginScene resets the uniform every frame, so a pass that forgets to
|
||||
-- clear it cannot leak into the next one.
|
||||
-- `amount` is how far toward that colour, 0..1; omitted is all the way.
|
||||
-- Anything short of 1 leaves the sprite's own shading showing through, which
|
||||
-- is the difference between a hit flash and a white cut-out.
|
||||
function Voxel3D.flatten(color, amount)
|
||||
if not (active and activeShader) then return end
|
||||
local sh = activeShader
|
||||
if color then
|
||||
pcall(sh.send, sh, "ghostColor", color)
|
||||
pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1)))
|
||||
else
|
||||
pcall(sh.send, sh, "ghost", 0)
|
||||
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)
|
||||
@@ -658,8 +933,18 @@ end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function Voxel3D.invalidate()
|
||||
for name, held in pairs(slots) do
|
||||
if held.canvas and held.canvas.release then
|
||||
pcall(held.canvas.release, held.canvas)
|
||||
end
|
||||
slots[name] = nil
|
||||
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
|
||||
|
||||
@@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- A pass that needs the wireframe whatever the player left the row on sets
|
||||
-- this for the length of its own draw and puts it back after. nil means
|
||||
-- "follow the setting", which is every frame outside such a pass.
|
||||
--
|
||||
-- The overworld battle is the one user: a fight is a STAGED shot, not the
|
||||
-- world being walked around in, and the seams are what make it read as
|
||||
-- constructed rather than as a photograph of somewhere. The row still owns
|
||||
-- what free-roam looks like, and is not written to -- switching the mode off
|
||||
-- mid-battle would silently rewrite the player's own setting.
|
||||
VoxelGrid.override = nil
|
||||
|
||||
function VoxelGrid.enabled()
|
||||
if VoxelGrid.override ~= nil then return VoxelGrid.override end
|
||||
return VoxelGrid.setting:get() and true or false
|
||||
end
|
||||
|
||||
|
||||
+236
-36
@@ -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,30 +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
|
||||
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
|
||||
-- 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
|
||||
|
||||
local function skyFor(map)
|
||||
-- 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)
|
||||
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 = 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
|
||||
local t = skyStrength(Voxel.angle)
|
||||
if t <= 0 then return nil end
|
||||
local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
|
||||
local c = shades[SKY_SHADE] or SKY_SHADES[SKY_SHADE]
|
||||
return { c[1] / 255, c[2] / 255, c[3] / 255, t }
|
||||
if not t or t <= 0 then return nil end
|
||||
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)
|
||||
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
|
||||
@@ -131,6 +185,9 @@ local function groundAt(map, cellX, cellY)
|
||||
end
|
||||
|
||||
VoxelScene.YAW = YAW
|
||||
-- shared with the overworld battle, which stands its mons on map cells and
|
||||
-- needs the same answer about what height "the floor" is there
|
||||
VoxelScene.groundAt = groundAt
|
||||
|
||||
-- Camera-ward pull distance for billboards (and the grass rows, which
|
||||
-- must keep their relative depth to feet): just enough that a leaned-back
|
||||
@@ -190,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
|
||||
@@ -220,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
|
||||
|
||||
@@ -370,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
|
||||
@@ -391,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
|
||||
@@ -427,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
|
||||
@@ -439,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
|
||||
|
||||
@@ -458,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
|
||||
@@ -498,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
|
||||
@@ -511,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
|
||||
@@ -543,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()
|
||||
|
||||
+52
-2
@@ -23,10 +23,60 @@
|
||||
|
||||
local Voxel = {}
|
||||
|
||||
Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" }
|
||||
-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
|
||||
-- its intended state at once -- this camera, the miniature blur at full, the
|
||||
-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
|
||||
-- it rather than assembling it from four rows. It sits directly after OFF
|
||||
-- because that is the order those two get used in.
|
||||
--
|
||||
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
|
||||
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
|
||||
-- like, and two rungs may look the same while meaning different things.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
Voxel.FULL_LEVEL = 1
|
||||
|
||||
function Voxel.isFull(level)
|
||||
return (level or Voxel.level) == Voxel.FULL_LEVEL
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
-- cycler: pressing it should change the camera and nothing else, and FULL
|
||||
-- reaches in and rewrites four other settings. Landing on it by accident,
|
||||
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
|
||||
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
|
||||
-- row, which is where a preset that changes other rows belongs.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
-- A level that is not on the key's path -- FULL, reached from the menu --
|
||||
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
|
||||
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
|
||||
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
|
||||
-- rung, so retuning FULL moves the key's answer with it.
|
||||
function Voxel.nextHotkeyLevel(level)
|
||||
level = level or Voxel.level
|
||||
local order = Voxel.HOTKEY_ORDER
|
||||
local at = nil
|
||||
for i, rung in ipairs(order) do
|
||||
if rung == level then at = i break end
|
||||
end
|
||||
if not at then
|
||||
local deg = Voxel.ANGLES_DEG[level + 1]
|
||||
for i, rung in ipairs(order) do
|
||||
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
|
||||
end
|
||||
end
|
||||
if not at then return order[1] end
|
||||
return order[at % #order + 1]
|
||||
end
|
||||
|
||||
Voxel.level = 0
|
||||
Voxel.angle = 0
|
||||
Voxel.from = 0
|
||||
|
||||
@@ -77,6 +77,16 @@ local TiltShift = V.require("TiltShift")
|
||||
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.
|
||||
-- Declared rather than left global -- a mod writing to _G would leak into
|
||||
-- every other mod's namespace.
|
||||
local applyFull
|
||||
|
||||
-- The last VOID FILL the terrain was meshed under; see the update hook.
|
||||
-- The scene canvas's size, in FRAMEBUFFER PIXELS.
|
||||
@@ -144,7 +154,26 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- pump slice -- so stepping out of a door lands on terrain that is
|
||||
-- already there instead of a flat flash.
|
||||
update = function(dt, level)
|
||||
-- FULL is a preset, so it is applied ON THE PRESS rather than held every
|
||||
-- frame: it SETS the other rows and then leaves them alone. Holding them
|
||||
-- would make the zoom keys and the wheel dead while the mode was on, and
|
||||
-- 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.
|
||||
-- What it needs is a tick that keeps running once the overworld stops
|
||||
-- being the top state, and this is one -- Game:update calls
|
||||
-- Pipelines.update unconditionally, so it survives the transition wipe
|
||||
-- and the whole battle. Ahead of the active() gate below, because a 3D
|
||||
-- battle does not require the free-roam mode to be switched on.
|
||||
OverworldBattle.update(dt)
|
||||
-- VOID FILL picks the block the border ring is made of, and in this
|
||||
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
|
||||
-- So the option has to reach the cache or nothing happens on screen
|
||||
@@ -186,6 +215,7 @@ mod.content.render_pipelines:register("voxel", {
|
||||
|
||||
invalidate = function()
|
||||
Voxel3D.invalidate()
|
||||
OverworldBattle.invalidate()
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
end,
|
||||
})
|
||||
@@ -222,10 +252,105 @@ mod.content.render_pipelines:register("tiltshift", {
|
||||
-- instead -- see ModSetting for where they persist and how the two rows
|
||||
-- each ends up on stay in step.
|
||||
|
||||
-- ------- the FULL preset
|
||||
--
|
||||
-- Everything the mode wants switched to at once. Applied when the VOXEL row
|
||||
-- ARRIVES at FULL and not again, so the player can still move the camera or
|
||||
-- the zoom afterwards -- it is a starting point, not a lock.
|
||||
--
|
||||
-- Leaving FULL deliberately does NOT undo any of it. A preset that reverted
|
||||
-- would throw away whatever the player had changed since, and "put it back
|
||||
-- how it was" is not a thing this can know.
|
||||
local fullWas = nil
|
||||
|
||||
applyFull = function(level)
|
||||
local isFull = Voxel.isFull(level)
|
||||
local was = fullWas
|
||||
fullWas = isFull
|
||||
if not isFull or was == true or was == nil then return end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local opts = Game.save and Game.save.options
|
||||
if not opts then return end
|
||||
|
||||
-- the miniature blur at its strongest: FULL is the diorama look, and the
|
||||
-- tilt-shift is most of what makes it read as a model
|
||||
Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift"))
|
||||
Pipelines.syncOptions(opts)
|
||||
-- the horizon flat. The curve bends the world away from a walking player,
|
||||
-- which fights a fixed diorama framing
|
||||
WorldCurve.setting:setIndex(1, Game)
|
||||
-- and the view fitted to the window
|
||||
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 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.",
|
||||
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 = {}
|
||||
@@ -236,17 +361,18 @@ mod.options:define(schema)
|
||||
|
||||
-- ------- this mod's hotkeys
|
||||
--
|
||||
-- 3 VOXEL cycle the camera ladder (was 6)
|
||||
-- 3 VOXEL cycle the camera ladder (was 6; skips FULL)
|
||||
-- 5 V-GRID toggle the wireframe (new)
|
||||
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
||||
-- 7 V-CURVE cycle the horizon bend (new)
|
||||
-- 8 3D-BTL toggle overworld battles (new)
|
||||
--
|
||||
-- Only 6 arrives by the documented route. Game:keypressed answers the
|
||||
-- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM,
|
||||
-- 5 GBC FX -- and only then offers the key to Pipelines.hotkey, expressly
|
||||
-- so "a pipeline can never shadow one" (Schemas, render_pipelines.hotkey).
|
||||
-- 3 and 5 are two of those, and 7 belongs to a pair of plain mod settings
|
||||
-- that own no pass and so have no registry to claim a key from at all.
|
||||
-- 3 and 5 are two of those, and 7 and 8 belong to plain mod settings that
|
||||
-- own no pass and so have no registry to claim a key from at all.
|
||||
--
|
||||
-- So this wraps Game:keypressed. It is the invasive option and it is the
|
||||
-- only one: polling the keyboard in update() would fire alongside the
|
||||
@@ -254,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
|
||||
@@ -268,6 +397,7 @@ local HOTKEYS = {
|
||||
["6"] = "pipeline", -- tiltshift, likewise
|
||||
["5"] = VoxelGrid.setting,
|
||||
["7"] = WorldCurve.setting,
|
||||
["8"] = OverworldBattle.setting,
|
||||
}
|
||||
|
||||
do
|
||||
@@ -283,7 +413,20 @@ do
|
||||
-- render mode. Only free-roam presses are ours to take.
|
||||
if claim and not (top and top.onKeyPressed) then
|
||||
if claim == "pipeline" then
|
||||
if Pipelines.hotkey(key, top, self.overworld) then
|
||||
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
|
||||
-- so the registry's plain "advance one and wrap" is not what it
|
||||
-- wants; 6 still is. The gate is the registry's own either way.
|
||||
local stepped = false
|
||||
if key == "3" then
|
||||
if Pipelines.canToggle("voxel", top, self.overworld) then
|
||||
Pipelines.setLevel("voxel",
|
||||
Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
||||
stepped = true
|
||||
end
|
||||
else
|
||||
stepped = Pipelines.hotkey(key, top, self.overworld) and true
|
||||
end
|
||||
if stepped then
|
||||
Pipelines.syncOptions(self.save.options)
|
||||
-- 3 is the key that used to turn TILT on and sits next to the one
|
||||
-- that used to turn GBC FX on, and this mod has taken both away.
|
||||
@@ -304,11 +447,20 @@ do
|
||||
return
|
||||
end
|
||||
elseif Pipelines.canToggle("voxel", top, self.overworld) then
|
||||
-- Both settings parameterise the voxel pass, so they answer to the
|
||||
-- same free-roam gate it does -- borrowed from the registry rather
|
||||
-- than restated, so a press mid-warp or mid-cutscene is refused for
|
||||
-- the wireframe exactly when it would be for the mode itself.
|
||||
-- All three answer to the voxel pass's own free-roam gate --
|
||||
-- borrowed from the registry rather than restated, so a press
|
||||
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
|
||||
-- it would be for the mode itself. Two of them parameterise that
|
||||
-- pass; the third (3D-BTL) decides what a battle is drawn over, and
|
||||
-- wants the same gate for a different reason: the answer is read
|
||||
-- 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
|
||||
@@ -316,15 +468,126 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the mode's rows, kept together
|
||||
--
|
||||
-- The engine splices a pipeline's row in beside TILT, because a display mode
|
||||
-- belongs with the other display modes; a mod's own ui.options.rows
|
||||
-- additions land at the END of the list. That left this mod's four rows in
|
||||
-- two places with unrelated engine rows between them, which reads as two
|
||||
-- unrelated features rather than one mode with settings.
|
||||
--
|
||||
-- So the plain settings are inserted directly after the last of this mod's
|
||||
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
|
||||
-- where the engine already decided display modes go.
|
||||
local function insertGrouped(out, extra)
|
||||
local anchor = nil
|
||||
for i, row in ipairs(out) do
|
||||
local id = type(row) == "table" and row.id
|
||||
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
|
||||
end
|
||||
if not anchor then
|
||||
for _, row in ipairs(extra) do out[#out + 1] = row end
|
||||
return out
|
||||
end
|
||||
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 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
|
||||
end
|
||||
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
|
||||
for _, entry in ipairs(SETTINGS) do
|
||||
out[#out + 1] = entry[1]:row()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
-- 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
|
||||
return out
|
||||
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
|
||||
-- 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)
|
||||
|
||||
-- The mod manager writes and persists on its own, so the only thing left
|
||||
@@ -334,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
|
||||
@@ -416,7 +688,167 @@ mod.events:on("map.reloaded", function(payload)
|
||||
if mapId then ChunkMesher.invalidate(mapId) end
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.0.6"
|
||||
-- ------- 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
|
||||
-- which rows the hook would return but not which rows were on screen -- the
|
||||
-- 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 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")
|
||||
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
|
||||
end
|
||||
|
||||
OptionsMenu.dramaticShapeFullHook = true
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- battles on the map
|
||||
--
|
||||
-- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and
|
||||
-- BattleState:drawHUDs -- all live in lib/OverworldBattle.lua, which is
|
||||
-- where the reasoning for each one is written down. Installed once, here,
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- The overworld's own pushBattle is the choke point for a wild encounter or
|
||||
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
|
||||
-- link battle, a script pushing a BattleState directly -- reaches this
|
||||
-- instead, which stages the arena from wherever the player is standing.
|
||||
-- Nothing visible is lost by being late: the cull only has to beat the
|
||||
-- battle screen, and the wipe those battles skip is where it would have
|
||||
-- shown.
|
||||
mod.events:on("battle.started", function(payload)
|
||||
OverworldBattle.ensure(payload and payload.battle)
|
||||
end)
|
||||
|
||||
-- Both mons face the camera, so the player's side wants its FRONT pic where
|
||||
-- the battle screen would have used the back one. The engine's own
|
||||
-- pokemon.sprite hook is the seam for exactly this: it is asked for every
|
||||
-- battle pic with the side it is resolving, so swapping one side's answer
|
||||
-- needs no battle code at all -- and every path that builds a battler goes
|
||||
-- through it, including a Transform mid-fight.
|
||||
--
|
||||
-- next() first, so a sprite-replacing mod loaded before this one still gets
|
||||
-- the last word on WHICH art is used; this only changes which SIDE is asked
|
||||
-- for.
|
||||
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
|
||||
local out = next(path, ctx)
|
||||
if not (ctx and ctx.kind == "battle" and ctx.side == "back") then
|
||||
return out
|
||||
end
|
||||
if not OverworldBattle.wantsFront() then return out end
|
||||
local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
|
||||
return (def and def.spriteFront) or out
|
||||
end)
|
||||
|
||||
-- Every ending path emits this, including a battle skipped before it drew,
|
||||
-- so this is where the map's cast comes back.
|
||||
mod.events:on("battle.ended", function()
|
||||
OverworldBattle.finish()
|
||||
end)
|
||||
|
||||
-- ------- 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
|
||||
|
||||
+6
-4
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.0.6",
|
||||
"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": [],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"permissions": [
|
||||
"engine_internals"
|
||||
],
|
||||
"affects_link": false,
|
||||
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines and claims hotkeys 3, 5, 6 and 7 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only."
|
||||
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
|
||||
}
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "The overworld as a 3D diorama: extruded terrain, real occlusion, voxel characters, tilt-shift miniature blur.",
|
||||
summary = "The overworld as a 3D diorama, and battles fought on it: real occlusion, tilt-shift, over-the-shoulder fights.",
|
||||
author = "DramaticShape",
|
||||
contact = "https://github.com/DramaticShape/DRAMATIC_SHAPE",
|
||||
tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation" },
|
||||
tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation",
|
||||
"battle" },
|
||||
differences = {
|
||||
changed = {
|
||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
||||
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
},
|
||||
known = {
|
||||
"needs shader and depth-canvas support; without them the row still cycles but the world stays 2D",
|
||||
"battles, menus and cutscenes are unaffected -- the mode only draws the free-roam overworld",
|
||||
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
|
||||
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
|
||||
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
|
||||
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
|
||||
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
|
||||
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
|
||||
},
|
||||
},
|
||||
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 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Driver: choose and photograph one battle arena per map.
|
||||
--
|
||||
-- data/battle_arenas.lua holds one authored spot per area. This is what
|
||||
-- authors it: for every map a battle can happen on, it asks BattleArena for
|
||||
-- the arena nearest the map's middle that its clearance test says both mons
|
||||
-- can be SEEN in, stages a real battle there, and takes one screenshot.
|
||||
--
|
||||
-- Two outputs. A `PICK` line per map, ready to paste into the data file, and
|
||||
-- a PNG per map to look at -- because the clearance test answers a geometry
|
||||
-- question ("nothing tall on the sightline") and the actual question is
|
||||
-- whether the picture reads. Grass and flowers around a mon's feet are fine
|
||||
-- and wanted; a body cut in half by a wall is not, and only an eye catches
|
||||
-- the difference.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
|
||||
--
|
||||
-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
|
||||
-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
|
||||
-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
|
||||
-- centred in the map's biggest body of it, which is what the surf routes
|
||||
-- want. SHOT_DIR must already exist -- the capture writes with io.open,
|
||||
-- which does not create directories.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
|
||||
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")
|
||||
|
||||
-- ------- staging out on the water
|
||||
--
|
||||
-- A surf route's land is a rim of beach round the edge of the map, so the
|
||||
-- ordinary search always picks the rim and the fight happens on sand at
|
||||
-- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
|
||||
-- counts as ground, and the search starts from the middle of the map's
|
||||
-- BIGGEST body of water rather than the middle of the map, so the arena
|
||||
-- lands out in the open sea instead of against the first shoreline it
|
||||
-- finds.
|
||||
--
|
||||
-- Biggest body, not all water at once: a map with a lake and an ocean has
|
||||
-- a centroid between them that is on neither, and the arena would be
|
||||
-- pinned to whichever shore that landed nearest.
|
||||
local function waterCentre(map)
|
||||
local w, h = map.widthCells, map.heightCells
|
||||
local seen, best = {}, nil
|
||||
for y0 = 0, h - 1 do
|
||||
for x0 = 0, w - 1 do
|
||||
if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
|
||||
-- one connected body, flooded from this cell
|
||||
local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
|
||||
seen[y0 * w + x0] = true
|
||||
while #stack > 0 do
|
||||
local cell = table.remove(stack)
|
||||
local cx, cy = cell[1], cell[2]
|
||||
n, sx, sy = n + 1, sx + cx, sy + cy
|
||||
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
|
||||
local nx, ny = cx + d[1], cy + d[2]
|
||||
local key = ny * w + nx
|
||||
if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
|
||||
and map:isWaterCell(nx, ny) then
|
||||
seen[key] = true
|
||||
stack[#stack + 1] = { nx, ny }
|
||||
end
|
||||
end
|
||||
end
|
||||
if not best or n > best.n then
|
||||
best = { n = n, x = sx / n, y = sy / n }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- Which maps a battle can actually happen on: anything with a wild
|
||||
-- encounter table or an object that fights. Everything else -- a shop
|
||||
-- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
|
||||
-- that never happens there.
|
||||
--
|
||||
-- The encounter tables are their OWN registry keyed by map id, not a field
|
||||
-- on the map record. An earlier cut of this read `def.encounters`, which is
|
||||
-- always nil -- so the list silently narrowed to trainer maps only and
|
||||
-- every route, cave and Safari zone fell out of it. Nothing failed; the
|
||||
-- tool just quietly stopped offering to author the places wild battles
|
||||
-- actually happen.
|
||||
local function fights(id, def)
|
||||
local enc = game.data.encounters and game.data.encounters[id]
|
||||
if enc then
|
||||
for _, kind in ipairs({ "grass", "water" }) do
|
||||
local t = enc[kind]
|
||||
if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
if obj.trainer or obj.trainerClass or obj.species then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local ids = {}
|
||||
local explicit = os.getenv("ARENA_MAPS")
|
||||
if explicit and explicit ~= "" then
|
||||
for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
|
||||
else
|
||||
for id, def in pairs(game.data.maps) do
|
||||
if type(id) == "string" and type(def) == "table" and fights(id, def) then
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(ids)
|
||||
end
|
||||
|
||||
local from = tonumber(os.getenv("ARENA_FROM") or "") or 1
|
||||
local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids
|
||||
U.log(("%d battle maps; doing %d..%d"):format(#ids, from,
|
||||
math.min(#ids, from + count - 1)))
|
||||
-- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter
|
||||
-- picks is the authoritative answer to "which areas need an arena", so it
|
||||
-- has to be readable without sitting through a capture -- that is how the
|
||||
-- bug above went unnoticed.
|
||||
if count <= 0 then
|
||||
for i = 1, #ids, 8 do
|
||||
U.log("LIST " .. table.concat(ids, ",", i, math.min(#ids, i + 7)))
|
||||
end
|
||||
U.log("done -- listing only")
|
||||
return
|
||||
end
|
||||
|
||||
for i = from, math.min(#ids, from + count - 1) do
|
||||
local id = ids[i]
|
||||
local def = game.data.maps[id]
|
||||
local cx = math.floor(def.width) -- the middle, in cells
|
||||
local cy = math.floor(def.height)
|
||||
|
||||
-- the pick is made against the MAP, before anything is staged, so a map
|
||||
-- with no clear arena at all is reported rather than quietly given a bad
|
||||
-- one
|
||||
local ok, err = pcall(function()
|
||||
U.teleport(game, id, 1, 1, "down")
|
||||
end)
|
||||
if not ok then
|
||||
U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
|
||||
else
|
||||
local map = game.overworld.map
|
||||
local clear, any
|
||||
-- ARENA_AT=x,y[,shape] photographs one specific spot instead of the
|
||||
-- picked one, which is how a map the automatic choice got wrong is
|
||||
-- re-aimed by hand and checked in the same loop.
|
||||
-- ARENA_MAP=OTHER_ID stages on another floor of the same cave or
|
||||
-- building, for a map that has nowhere of its own to put a fight
|
||||
local at = os.getenv("ARENA_AT")
|
||||
local onMap = os.getenv("ARENA_MAP")
|
||||
if at and at ~= "" and (explicit and explicit ~= "") then
|
||||
local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$")
|
||||
if ax then
|
||||
-- forced through the authored-entry seam, so what gets staged is
|
||||
-- exactly what was asked for rather than whatever the search
|
||||
-- would have picked from the player's cell
|
||||
local onCam = os.getenv("ARENA_CAM")
|
||||
Arena.setOverride(id, {
|
||||
x = tonumber(ax), y = tonumber(ay),
|
||||
shape = (ashape ~= "" and ashape) or "wide",
|
||||
map = (onMap ~= "" and onMap) or nil,
|
||||
cam = (onCam and onCam ~= "" and onCam) or nil,
|
||||
})
|
||||
any = Arena.find(map, 0, 0, false)
|
||||
clear = any and Arena.clearance(any.map or map, any) or false
|
||||
end
|
||||
end
|
||||
local surf = os.getenv("ARENA_SURF")
|
||||
surf = surf ~= nil and surf ~= "" and surf ~= "0"
|
||||
if not any then
|
||||
local ox, oy = cx, cy
|
||||
if surf then
|
||||
local sea = waterCentre(map)
|
||||
if sea then
|
||||
ox, oy = sea.x, sea.y
|
||||
U.log(("SEA %s: biggest body is %d cells, centre %.1f,%.1f")
|
||||
:format(id, sea.n, sea.x, sea.y))
|
||||
else
|
||||
U.log("SEA " .. id .. ": no water on this map")
|
||||
end
|
||||
end
|
||||
clear = Arena.search(map, ox, oy, surf, true)
|
||||
any = clear or Arena.search(map, ox, oy, surf)
|
||||
end
|
||||
if not any then
|
||||
U.log(("NONE %s -- no arena of either shape"):format(id))
|
||||
else
|
||||
local onOther = any.map and any.map.id ~= id and any.map.id or nil
|
||||
U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s },%s")
|
||||
:format(id, onOther and ("map = %q, "):format(onOther) or "",
|
||||
any.x, any.y, any.shape,
|
||||
any.cam and (", cam = %q"):format(any.cam) or "",
|
||||
clear and "" or " -- OBSTRUCTED: no clear spot"))
|
||||
-- stand the player on the arena so the staged battle uses it, then
|
||||
-- fight something there and photograph the result
|
||||
game.overworld.player.cellX = any.playerCell[1]
|
||||
game.overworld.player.cellY = any.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 %s at %s,%s"):format(id,
|
||||
tostring(got and got.x), tostring(got and got.y)))
|
||||
U.shot(game, ("%s/%s.png"):format(DIR, id:lower()))
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Driver: screenshot an overworld battle against eight random trainers.
|
||||
--
|
||||
-- The mode's whole claim is that a fight reads as happening on the map, so
|
||||
-- what has to be looked at is a spread of maps: open route, town, forest,
|
||||
-- cave, gym floor. Each one gets the same three beats -- the menu (both HUD
|
||||
-- panels up over whatever ground they landed on), a move animation, and the
|
||||
-- frame a few seconds later, which is where the parallax drift shows.
|
||||
--
|
||||
-- The eight are drawn with a fixed seed, so a re-run after a tweak produces
|
||||
-- the same eight battles and the shots compare directly.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/battle_shots.lua love .
|
||||
--
|
||||
-- SHOT_DIR must already exist: the capture writes with io.open, which does
|
||||
-- not create directories.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local SEED = tonumber(os.getenv("SHOT_SEED") or "") or 20260727
|
||||
love.math.setRandomSeed(SEED)
|
||||
|
||||
-- Somebody to fight with. Two very different back pics, so a pin that is
|
||||
-- wrong for one silhouette and right for another cannot hide.
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 40),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- Where to stage them: outdoor, wooded, urban, underground, indoor. Any id
|
||||
-- the merged dataset does not have is dropped rather than guessed at.
|
||||
local PLACES = {
|
||||
{ "ROUTE_1", 5, 8 },
|
||||
{ "VIRIDIAN_FOREST", 12, 20 },
|
||||
{ "PALLET_TOWN", 5, 6 },
|
||||
{ "MT_MOON_1F", 12, 12 },
|
||||
{ "CERULEAN_CITY", 10, 12 },
|
||||
{ "ROUTE_25", 12, 5 },
|
||||
{ "PEWTER_GYM", 4, 8 },
|
||||
{ "ROUTE_4", 10, 5 },
|
||||
{ "VIRIDIAN_CITY", 20, 20 },
|
||||
{ "ROUTE_3", 10, 5 },
|
||||
}
|
||||
local places = {}
|
||||
for _, p in ipairs(PLACES) do
|
||||
if game.data.maps[p[1]] then places[#places + 1] = p end
|
||||
end
|
||||
|
||||
-- every trainer class the merged data carries, in a stable order so the
|
||||
-- seed picks the same ones every run
|
||||
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(("%d trainer classes, %d places, seed %d")
|
||||
:format(#classes, #places, SEED))
|
||||
|
||||
local Battles = nil
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if lib then Battles = lib.require("OverworldBattle") end
|
||||
if not Battles then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
end
|
||||
|
||||
local function label(place, class)
|
||||
local arena = Battles and Battles.arena()
|
||||
if not arena then
|
||||
return ("%s / %s -- NO ARENA (plain battle screen)")
|
||||
:format(place[1], class)
|
||||
end
|
||||
return ("%s / %s -- %s arena at cell %d,%d; enemy %d,%d player %d,%d")
|
||||
:format(place[1], class, arena.shape, arena.x, arena.y,
|
||||
arena.enemyCell[1], arena.enemyCell[2],
|
||||
arena.playerCell[1], arena.playerCell[2])
|
||||
end
|
||||
|
||||
for i = 1, 8 do
|
||||
-- the TRAINERS are the random part; the places are walked in order, so
|
||||
-- one run covers open route, town, forest, cave and gym floor rather
|
||||
-- than landing on the same meadow eight times
|
||||
local place = places[(i - 1) % #places + 1]
|
||||
local class = classes[love.math.random(#classes)]
|
||||
local rec = game.data.trainers[class]
|
||||
local partyIndex = love.math.random(#rec.parties)
|
||||
local tag = ("%02d_%s_%s"):format(i, place[1]:lower(), class:lower())
|
||||
|
||||
U.teleport(game, place[1], place[2], place[3], "down")
|
||||
-- let the neighbourhood's meshes land before the fight starts, so the
|
||||
-- first battle frame is not the flat fallback
|
||||
U.wait(90)
|
||||
|
||||
local battle = BattleState.newTrainer(game, class, partyIndex)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
-- the wipe, then the send-out chatter
|
||||
U.wait(70)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||
U.log(label(place, class))
|
||||
U.shot(game, ("%s/%s_1_menu.png"):format(DIR, tag))
|
||||
|
||||
-- FIGHT -> first move -> the animation
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(24)
|
||||
U.shot(game, ("%s/%s_2_anim.png"):format(DIR, tag))
|
||||
|
||||
-- and a while later, where the drift has moved the world under them
|
||||
U.wait(240)
|
||||
U.shot(game, ("%s/%s_3_drift.png"):format(DIR, tag))
|
||||
|
||||
-- out of the battle and on to the next: pop whatever is above the
|
||||
-- overworld rather than trying to play the fight to its end
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -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
|
||||
+1909
-31
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the per-map battle-arena screenshots into contact sheets.
|
||||
|
||||
One shot per map is a lot of files to open one at a time, so this lays them
|
||||
out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which
|
||||
is enough to judge a whole region at a glance and spot the one that is wrong.
|
||||
|
||||
python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \
|
||||
.scratchpad/areas .scratchpad/sheets [order.txt]
|
||||
|
||||
Reads every PNG in the source directory and writes sheet_01.png,
|
||||
sheet_02.png ... into the destination.
|
||||
|
||||
The optional third argument is a file of map ids, one per line, which both
|
||||
FILTERS and ORDERS the sheets. That is what makes them worth looking at: the
|
||||
authored list in data/battle_arenas.lua is grouped by region, so feeding its
|
||||
keys through puts the routes together and the caves together, instead of
|
||||
alphabetical order interleaving a gym with a tower floor. Without it every
|
||||
PNG in the directory is used, sorted by name.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
PER_SHEET = 8
|
||||
COLS, ROWS = 2, 4
|
||||
TILE_W = 960 # each shot is 3840 wide; a quarter of that
|
||||
LABEL_H = 34
|
||||
PAD = 8
|
||||
BG = (18, 18, 20)
|
||||
LABEL_BG = (32, 32, 36)
|
||||
LABEL_FG = (232, 232, 236)
|
||||
|
||||
|
||||
def font(size):
|
||||
for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def tile(path, w, h, label, f):
|
||||
"""One labelled cell: the shot scaled to fit, with its map id under it."""
|
||||
cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG)
|
||||
try:
|
||||
shot = Image.open(path).convert("RGB")
|
||||
except OSError as err:
|
||||
d = ImageDraw.Draw(cell)
|
||||
d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f)
|
||||
return cell
|
||||
shot.thumbnail((w, h), Image.LANCZOS)
|
||||
cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2))
|
||||
d = ImageDraw.Draw(cell)
|
||||
d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG)
|
||||
d.text((10, h + 7), label, fill=LABEL_FG, font=f)
|
||||
return cell
|
||||
|
||||
|
||||
def wanted(src, order_path):
|
||||
"""The PNGs to lay out, in the order to lay them out in."""
|
||||
have = {n.lower(): n for n in os.listdir(src)
|
||||
if n.lower().endswith(".png")}
|
||||
if not order_path:
|
||||
return sorted(have.values()), []
|
||||
names, missing = [], []
|
||||
with open(order_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
key = line.strip()
|
||||
if not key or key.startswith("#"):
|
||||
continue
|
||||
hit = have.get(key.lower() + ".png")
|
||||
if hit:
|
||||
names.append(hit)
|
||||
else:
|
||||
missing.append(key)
|
||||
return names, missing
|
||||
|
||||
|
||||
def main(src, dst, order_path=None):
|
||||
names, missing = wanted(src, order_path)
|
||||
if not names:
|
||||
sys.exit("no PNGs to lay out from " + src)
|
||||
# said out loud rather than silently skipped: a sheet set that quietly
|
||||
# dropped a map would read as "every area is covered" when it is not
|
||||
for key in missing:
|
||||
print("MISSING no shot for %s" % key)
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
|
||||
probe = Image.open(os.path.join(src, names[0]))
|
||||
tile_h = round(TILE_W * probe.height / probe.width)
|
||||
f = font(24)
|
||||
sheet_w = COLS * TILE_W + (COLS + 1) * PAD
|
||||
sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD
|
||||
|
||||
made = []
|
||||
for page, start in enumerate(range(0, len(names), PER_SHEET), 1):
|
||||
batch = names[start:start + PER_SHEET]
|
||||
sheet = Image.new("RGB", (sheet_w, sheet_h), BG)
|
||||
for i, name in enumerate(batch):
|
||||
cell = tile(os.path.join(src, name), TILE_W, tile_h,
|
||||
os.path.splitext(name)[0].upper(), f)
|
||||
col, row = i % COLS, i // COLS
|
||||
x = PAD + col * (TILE_W + PAD)
|
||||
y = PAD + row * (tile_h + LABEL_H + PAD)
|
||||
sheet.paste(cell, (x, y))
|
||||
out = os.path.join(dst, "sheet_%02d.png" % page)
|
||||
sheet.save(out, optimize=True)
|
||||
made.append((out, [os.path.splitext(n)[0] for n in batch]))
|
||||
print("%s %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b)))
|
||||
|
||||
print("\n%d shots -> %d sheets" % (len(names), len(made)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) not in (3, 4):
|
||||
sys.exit(__doc__)
|
||||
main(sys.argv[1], sys.argv[2],
|
||||
sys.argv[3] if len(sys.argv) == 4 else None)
|
||||
Reference in New Issue
Block a user