Compare commits

...

39 Commits

Author SHA1 Message Date
DramaticShape 500556c3fc iterate version to 1.2.1 2026-07-30 00:11:02 -04:00
DramaticShape eabc8af716 android fix for day night cycle issue 2026-07-30 00:04:52 -04:00
DramaticShape 14f844ad8e fix issue with fight transition in oak's lab 2026-07-29 23:13:36 -04:00
DramaticShape 50f8f6bfe3 Merge branch '3d-battle-mod' of https://github.com/DramaticShape/DramaticShapeVoxelMod into 3d-battle-mod
# Conflicts:
#	README.md
2026-07-29 22:54:22 -04:00
DramaticShape 284172db7b add clock sync option 2026-07-29 22:50:54 -04:00
DramaticShape 6d27d91411 fix glinting on windows and striping 2026-07-29 22:43:20 -04:00
DramaticShape f2a18b62b2 Merge branch 'master' into 3d-battle-mod 2026-07-29 22:30:51 -04:00
DramaticShape 5ff56eab46 bump version 2026-07-29 22:29:23 -04:00
DramaticShape 99e18f7b5b set viridian to night time lighting 2026-07-29 22:27:25 -04:00
DramaticShape 9c263f0937 glass masking 2026-07-29 22:23:21 -04:00
DramaticShape 7e3245a9ef more shadow fixes, fix tree borders 2026-07-29 22:23:08 -04:00
DramaticShape 18857e039a fixed disconnected shadows 2026-07-29 21:51:42 -04:00
DramaticShape f841378330 add day night cycle 2026-07-29 21:42:34 -04:00
DramaticShape 6d7a93dbad remove voxel grid from overworld sprites 2026-07-29 20:09:18 -04:00
DramaticShape e14953c78a mitigate diagonal shadow artifacts 2026-07-29 20:06:18 -04:00
DramaticShape 8dc046f72e add skybox 2026-07-29 19:53:12 -04:00
DramaticShape c3ba10e17a added battle transition into overworld 2026-07-29 19:09:48 -04:00
DramaticShape 41a86b5fb2 second pass at indigo plateau 2026-07-29 19:03:11 -04:00
DramaticShape 9a34ba9c77 force 3d battle ui, initial victory road updates 2026-07-29 18:45:00 -04:00
DramaticShape 097bdcd14b fix poke mart back wall 2026-07-29 18:16:29 -04:00
DramaticShape e838ff4558 made cash register a sprite 2026-07-29 18:01:47 -04:00
DramaticShape 8e7a80055d fix couch man 2026-07-29 17:36:03 -04:00
DramaticShape 5ea70a278f update route one battle location 2026-07-29 16:22:53 -04:00
DramaticShape 60d5362c89 fix couch man 2026-07-29 15:44:52 -04:00
DramaticShape 3c1781f6b1 fix vertical line in battle sprites 2026-07-29 15:35:28 -04:00
DramaticShape b21fd46ea7 Remove battle staging details from README
Removed section about battle staging and conditions.
2026-07-28 18:49:07 -04:00
DramaticShape 917fbac38c Merge pull request #7 from DramaticShape/3d-battle-mod
fix white screen flash on shake on versions > 1.3.0
2026-07-28 16:16:45 -04:00
DramaticShape 0f303e7975 bump required version to 0.1.32 2026-07-28 16:14:43 -04:00
DramaticShape d8d1d7e336 fix white screen flash on shake on versions > 1.3.0 2026-07-28 16:10:58 -04:00
DramaticShape 4e6aa30c2c Merge pull request #6 from DramaticShape/3d-battle-mod
fixed full screen flashing in battles
2026-07-28 15:22:35 -04:00
DramaticShape 2e0ae37bcd fixed full screen flashing in battles 2026-07-28 15:22:13 -04:00
DramaticShape 9207712b19 Merge pull request #5 from DramaticShape/3d-battle-mod
set FULL tilt to 35
2026-07-28 14:01:54 -04:00
DramaticShape 2447046aee set FULL tilt to 35 2026-07-28 14:00:03 -04:00
DramaticShape 458a1d7bf9 Merge pull request #4 from DramaticShape/3d-battle-mod
3d battle mod
2026-07-28 13:38:39 -04:00
DramaticShape 5781c28b60 added FULL menu option for voxel mod 2026-07-28 13:37:19 -04:00
DramaticShape 6280cdffe7 Added overworld battles 2026-07-28 13:01:38 -04:00
DramaticShape acc66b51bb Merge pull request #3 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:19:36 -04:00
DramaticShape 894a4b4a55 Merge pull request #2 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:13:57 -04:00
DramaticShape 2a4269b20e Merge pull request #1 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 04:58:07 -04:00
37 changed files with 9359 additions and 343 deletions
+3
View File
@@ -4,6 +4,8 @@
# The SDK suite and the probes it grew out of. A shipped test that requires
# an engine module reads as a private require against the archive
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
tests/arena_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
+370 -1
View File
@@ -1,6 +1,375 @@
# Changelog
## Unreleased
## 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
+14 -6
View File
@@ -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,8 @@ 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 **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; 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.
+189
View File
@@ -0,0 +1,189 @@
-- 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
["ROUTE_1"] = { x = 9, y = 16, 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
View File
@@ -70,6 +70,21 @@
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- And `prop_bg` (not a class either): which GB shades count as BACKGROUND
-- when a pinned per-pixel prop is cut out, for the drawings whose own body
-- reaches the edge of their bounding box and so vote themselves away.
-- Keyed by tile, because two props sharing an atlas can want opposite
-- answers on the same shade.
--
-- And it may carry `figures` (not a class either): hand-drawn pixel masks
-- that lift a FIGURE PAINTED INTO furniture off it and stand it up as a
-- standee, leaving the furniture its own geometry. A pin resolves a
-- whole 8x8 tile, so it can never separate two things that share one --
-- and nothing automatic can either when the figure has no background
-- margin to flood from and wears the same shades as what it sits on.
-- The Pokemon Center's seated man is the case; see POKECENTER below for
-- the format.
--
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
-- and no single class covers that. They live in the `buildings` list at
@@ -87,6 +102,9 @@ return {
fence = 10,
sign = 12,
wall = 16,
-- masonry drawn two courses tall (the Indigo Plateau's rim, the
-- badge-check gates): as tall as a statue on its plinth
cliff = 32,
tree = 16,
roof = 28,
bed = 7,
@@ -694,22 +712,17 @@ return {
-- bodies so they stand ON them) and the PC (66/70/82/86), which
-- stands on its pinned desk
billboard = { 58, 59, 66, 70, 74, 75, 82, 86 },
-- Why the man on the couch rides the top face instead of standing
-- up. He is drawn across two tile rows (skin pixels appear in
-- rows 8 and 9 and stop dead at the row 9/10 seam), and folding
-- two rows upright requires both to share a class, which makes the
-- box two tiles deep -- and a fully folded box repeats its north
-- row across its whole top face. So every upright arrangement
-- puts his head on screen two or three times: as a 16px seat-back
-- his head lands on the front AND twice on the top; as a 32px
-- bookcase he becomes a cabinet taller than the room's walls.
-- Dropping the seat to floor level to clear his face does not help
-- either, it only moves which copy you see. He also cannot be a
-- standee: the drawing has no floor margin, so all three non-black
-- shades touch the cluster rim and the mask drains 307 of its 420
-- interior pixels -- 46% of him even segmented alone, because his
-- skin is the same light shade as the couch behind him. Riding
-- the top face is the one arrangement that draws him exactly once.
-- The man on the couch, cut out by hand and stood up (see `figures`
-- below). Nothing automatic reaches him. A class pin resolves a
-- whole 8x8 tile and he SHARES his tiles with the couch, so pinning
-- them stands the furniture up with him; riding the couch's top
-- face (what this did before) draws him lying flat on the cushion;
-- and he cannot be segmented into a standee either, because the
-- drawing has no background margin for a flood to enter by and his
-- skin is the same light shade as the couch -- the mask drained 307
-- of his 420 interior pixels, 46% of him, even segmented alone.
-- An authored mask is the only thing that can tell a man from the
-- sofa he is painted into, so that is what `figures` carries.
-- the PC's desk body, which the PC stands on
table = { 9, 88 },
-- the potted plants: bush (32/33/48/49) over pot (34/35/50/51,
@@ -721,6 +734,82 @@ return {
-- them; the standee reads darker than the flat art, which is the
-- accepted trade for a real plant silhouette
prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
-- ...but the POTS were draining outright. The plant pair is drawn
-- as one 4x4-tile block and the pot's olive base is flush on that
-- block's bottom row, so the background vote -- which reads the
-- shades touching the drawing's own bounding box -- came back with
-- "dark" in it, and every dark pixel in the plant left with the
-- floor. The pots rendered as hollow black frames around one or
-- two surviving pixels while the flat art has solid olive bodies.
--
-- So name the background outright for these eight tiles: the floor
-- IS light and white here, and nothing else is. That restores 152
-- pixels of the block (55% -> 69% of it kept) and cannot move any
-- other prop, which matters -- the same call tileset-wide would pull
-- the dark wall band into the healing consoles' screens and strip
-- three pixels off the PC, because those two want the opposite
-- answer on the very same shades.
prop_bg = {
{ tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
shades = { "light", "white" } },
},
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and
-- spills out of it in BOTH directions, which is why he needs three
-- tile columns:
--
-- 36 / 52 the couch's west arm. 36's two RIGHTMOST columns are
-- the back of his head; 52 is the plain arm, and is also
-- what 36 wears once his hair comes off it
-- 37 / 53 him: head, then face and body
-- 57 / 60 the floor east of the couch, which his hair and his
-- foot overhang. 1 and 26 are those same two floor
-- tiles as the artist drew them WITHOUT him -- 8 and 5
-- pixels apart respectively -- so lifting him off is
-- lossless there
--
-- `pixels` is the hand-drawn line between the man and the furniture;
-- `under` is what each tile wears once he is lifted off it. The
-- couch keeps its polygonal box and he stands on top of it.
--
-- The mask keeps ALL of 37/53's column 7, the couch's east edge.
-- Where that column is drawn dark it is the couch's own rule -- but
-- it is also where his hair crosses the tile seam, so dropping it
-- floats the overhang free of his head. And where it is drawn WHITE
-- it is the right side of his face, so dropping it opens a slit down
-- his cheek (rows 5-8, which is exactly what the first cut did).
-- Keeping the rule twice costs nothing: tile 39 redraws it on the
-- couch beneath him either way. Repainting 36 as 52 does cost one
-- row -- 36's top trim band, which 52 does not carry -- and that is
-- the accepted price for not leaving a second copy of his head lying
-- on the arm. The background corners around his head and the
-- cushion wedge under his legs are the only pixels given back.
figures = {
{
w = 3,
tiles = { 36, 37, 57,
52, 53, 60 },
under = { 52, 39, 1,
52, 39, 26 },
pixels = {
"..........XXXXX.........",
"........XXXXXXXX........",
".......XXXXXXXXXX.......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"........XXXXXXXXX.......",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXXX.......",
".........XXXXXXXXX......",
"...........XXXXXX.......",
"..............XX........",
},
},
},
},
-- The Poke Marts (one 4x4 layout serves every city; the tileset
@@ -731,12 +820,61 @@ return {
-- boxed the two free-standing shelf racks into one 4-tile-deep
-- monolith.
MART = {
-- the wall band stays one 16px face: the trim and case tops
-- (40/90/91), the SALE cases (78/79) with their feet (23/29), and
-- the glass drink fridges (44-47) with their feet (62/63) -- all
-- drawn built INTO the back wall, exactly like the Center's
-- healing consoles
wall = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 78, 79, 90, 91 },
-- THE BACK WALL. It is drawn FOUR tile rows tall -- trim (40, or
-- the fridge tops 90/91), the SALE signs (78/79) or glass upper
-- (44/45), the black display niche (76/77) or glass lower (46/47),
-- and the cases' base and goods (23/29, 62/63) -- which is 32px of
-- artwork depicting ONE wall, not four things at four depths.
--
-- Pinned `wall` that is exactly what it became: every row got its
-- own 16px box marching north, so the only face you ever saw was
-- the southmost row (the cases' base), the signs and the niche hid
-- behind it, and the trim ended up lying flat as a gold shelf on
-- the roofs of the boxes behind. Laid out in depth instead of
-- stacked up.
--
-- `bookcase` is the class that collapses a tall drawing onto a
-- one-cell-deep box at its real drawn height, so the run of four
-- rows becomes a single 32px wall: bands from the south are base,
-- niche, sign, trim, its top face wears the trim, and the three
-- rows behind become hidden floor. Same treatment the shelf racks
-- below already get -- a Mart's back wall IS a wall of display
-- cases, and the geometry does not care which we call it.
bookcase = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 76, 77, 78, 79,
90, 91,
-- the free-standing shelf racks: TALL drawings, not
-- deep ones -- each rank collapses onto a
-- one-cell-deep shelf at its drawn height (the
-- Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as
-- its top display; 68/69/71 and 84/85/87 the goods
-- rows below
64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- The wall's own tiles are reused elsewhere, and the back wall is
-- the one place they are drawn on the map's TOP EDGE. So `bookcase`
-- is their default and this hands every other use back to `wall`,
-- which is what all of them were before: 40 is also the clerk's
-- booth back panel (under the shelf rows 80/81 or under itself), and
-- 40/76/77/90/91 all recur in INDIGO_PLATEAU_LOBBY, the ninth map on
-- this id, where they draw the hall and the lift bank.
--
-- NEVER put 0 in an `above` set. Map:tileAt does not answer nil off
-- the top of a map -- it border-extends, so row 0 reads the map's
-- borderBlock, which indoors is the black void block 0. Listing 0
-- to catch the Lobby's void-backed 90/91 fired the rule on every
-- Mart's fridge row as well: row 0 dropped out of the bookcase run,
-- the fridges came out 24px against the SALE cases' 32px, and their
-- trim row stood as a separate box BEHIND the wall instead of on top
-- of it. Nothing can separate those two cases from above -- both
-- see void -- so the Lobby (already out of scope here) gets the
-- bookcase reading too, and the Marts come out right.
when_above = {
[40] = { { above = { 40, 80, 81, 83, 90, 91 }, class = "wall" } },
[76] = { { above = { 74, 75 }, class = "wall" } },
[77] = { { above = { 74, 75 }, class = "wall" } },
[90] = { { above = { 17 }, class = "wall" } },
[91] = { { above = { 27 }, class = "wall" } },
},
-- the clerk's counter, half a cell high like every service
-- counter. It is a C wrapping the alcove the clerk stands in: the
-- south arm's drawn front (24/25) under its top band (8/56), the
@@ -746,43 +884,66 @@ return {
-- cash register, pinned it `billboard`, and got a bare black
-- outline standing on the counter with the tile's own art replaced
-- by its neighbour's.
counter = { 8, 16, 24, 25, 41, 56, 89 },
-- The CASH REGISTER (14/15 over 30/31), a keypad and a curl of
-- receipt paper drawn face-on across two tile rows in the middle of
-- the counter's east arm. Pinned `counter` it was just paint on
-- the work surface. In the `billboard` pool it is a standing
-- per-pixel cutout ten voxels deep -- the flower-and-vase treatment
-- of Red's house, but with a machine's body rather than a stem --
-- and the support rule lifts it onto the counter automatically,
-- because the tile drawn directly BELOW it (16/41) is a pinned 8px
-- box. Its own pool, so the counter can never absorb it. The art
-- has a clean light margin on three sides and a black outline all
-- round, so the shade flood drains the work surface away and leaves
-- the machine whole.
--
-- `console`, not `billboard`, for the two vertical black rules the
-- COUNTER draws down the outer edges of those same tiles (columns
-- 0 and 15 of the pair, full height). They are the counter's own
-- edging, not the register, but black always survives the shade
-- flood, so the billboard pool extruded them too and the machine
-- stood flanked by a pair of tall black slabs. A white column
-- separates them from the register body, so they are their own
-- connected components -- and `console` carries the same
-- one-object contract `cutout` has, which keeps the largest
-- drawing and drops them, at ten voxels of body rather than one.
console = { 14, 15, 30, 31 },
-- the free-standing shelf racks: TALL drawings, not deep ones --
-- each rank collapses onto a one-cell-deep shelf at its drawn
-- height (the Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as its top
-- display; 68/69/71 and 84/85/87 the goods rows below
bookcase = { 64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- ...and the register's own two tile rows (14/15 over 30/31) are
-- part of that same work surface now that the machine has been cut
-- off them as a sprite -- see `figures` below.
counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 },
-- Left to the derived default on purpose: the floor checker and
-- its shadowed variants (1/11/17/26/27/54) and the exit mat
-- (12/28) all sit in cells the ROM marks walkable, so the cell
-- rule lays them flat unaided; 76/77 is the SALE case's black
-- interior, which the volume path recesses half a course into the
-- band -- a dark display niche, which is what it is drawn as.
-- rule lays them flat unaided. (76/77, the SALE case's black
-- interior, used to be left to the volume path here; it is now the
-- back wall's third band, because a run of four rows has to be
-- contiguous for the wall to collapse into one box at all.)
-- THE CASH REGISTER: a keypad and a curl of receipt paper drawn
-- face-on across two tile rows in the middle of the counter's east
-- arm. Like the Center's seated man it is a FIGURE drawn into the
-- furniture, so it gets the same treatment -- a flat sprite card
-- standing on the counter -- and for the same reason: no class pin
-- can separate it from the counter, because the counter draws its
-- own edging down columns 0 and 15 of the very same tiles.
--
-- That edging is what defeated every automatic reading. Black
-- always survives the shade flood, so the standee pools extruded
-- the two rules along with the machine and it stood flanked by a
-- pair of tall black slabs; `console`'s keep-the-largest-drawing
-- rule dropped them, but only by throwing away the receipt curl
-- too whenever the flood happened to cut it loose. The mask just
-- says where the machine is: columns 2-13, plus the curl climbing
-- to the top right, and the counter keeps its edging and its light
-- work surface.
--
-- `under` is 16/41 twice -- the plain work surface, which already
-- carries the west edging (black over white) and the east (dark
-- over black), so the counter closes up behind the machine with no
-- synthesis at all.
figures = {
{
w = 2,
tiles = { 14, 15,
30, 31 },
under = { 16, 41,
16, 41 },
pixels = {
".........XX.....",
"........XXXX....",
"........XXXXX...",
"........XXXXXX..",
"...XXXXXXXXXXX..",
"..XXXXXXXXXXXX..",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
},
},
},
-- NOT covered: INDIGO_PLATEAU_LOBBY, the ninth map on this id and
-- the only one that is not the 4x4 shop. It draws its own hall,
-- lift bank and rope stanchions from tiles no Mart places, three
@@ -2100,6 +2261,231 @@ return {
-- south-east). Neither map places them; they belong with the
-- wall band above if one ever does.
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
-- TWO courses (32px) for the masonry that ENCLOSES both maps -- the
-- plateau's rim and every wall around the terraces. It is drawn two
-- cells tall, which is exactly the height of a statue on its plinth
-- (a 16px plinth under a 16px standee), and that is the read: you are
-- walking in a walled compound whose wall matches the statues lining
-- it, not a room with a 16px skirting. At one course the rim was a
-- kerb you appeared to look over.
--
-- $03 the striated rock face, $0D/$0F/$0E the League's outer wall
-- (top band, face, base -- the same three tiles draw the League's
-- facade, the walls flanking the avenue and every badge-check gate
-- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its
-- cap. These four groups are the enclosure.
cliff = { 3, 13,
32, 33, 46, 47 },
-- THE GATE WALLS, stacked rather than laid out in depth. $0D/$0F/$0E
-- (13/15/14 -- top band, face, base) draw the League's facade, the
-- walls flanking the avenue and every badge-check gate down ROUTE_23,
-- as FOUR tile rows: 13 / 15 / 15 / 14. That is 32px of artwork
-- depicting one wall, and at one class per row it built four boxes
-- marching north, so the wall was 32px DEEP -- you saw the southmost
-- row's face and the rest hid behind it.
--
-- `bookcase` collapses the run onto a single one-cell-deep box at its
-- full drawn height: bands from the south are base, face, face, top
-- band, and the two rows behind are vacated. Same mechanism the
-- Mart's back wall uses.
-- ...and the PILASTER stacks with it. $05/$06 is its shaft, and it
-- is drawn only ever inside a pilaster, so it needs no rule. Without
-- this the pale columns stood 16px against a 32px brown wall and
-- their top vertices sat a whole course below the wall's crown.
bookcase = { 14, 15, 5, 6 },
-- Tile 13 is DUAL-USE and needs resolving per position. It is the
-- gate wall's TOP BAND (block $28's first row) and it is also the
-- BASE COURSE under a column of rock face (blocks $18/$1A/$1B, last
-- row). Pinned `bookcase` outright the second use became a one-row
-- rank -- an 8px stub under the rock, 352 of them over both maps.
--
-- ABOVE cannot tell them apart, which is what `when_below` is for.
-- Scanned over both maps through the engine's own tileAt, the tile
-- above a 13 is the rock face $03 for 140 base courses AND for 64
-- gate bands -- so a rule on `above` misfires on those 64 (it did:
-- their runs came out 2 and 3 bands instead of 4). BELOW splits it
-- exactly: the wall's own face $0F sits under the top band and under
-- nothing else, 336 against 352. So 13 defaults to `cliff` and is
-- promoted where the face is drawn beneath it.
--
-- 14 and 15 need no rule: 14 only ever sits under 15, 15 only ever
-- under 13 or 15.
when_below = {
[13] = { { below = { 15 }, class = "bookcase" } },
-- The pilaster's CAP ($15/$16) is the same stone as the statue's
-- plinth top -- the artist drew it twice -- so it too resolves per
-- position. Scanned over both maps: a cap with the shaft $05/$06
-- beneath it is a pilaster (76 of them) and one with the plaque
-- base $30/$31 beneath it is a statue's plinth (47). Default
-- `wall`, promoted here, so the plinth keeps its single course and
-- the statue standing on it still totals the 32px the wall is.
[21] = { { below = { 5 }, class = "bookcase" } },
[22] = { { below = { 6 }, class = "bookcase" } },
},
-- The other two ends of the same pair of drawings, keyed the other
-- way round. A pilaster is capped at BOTH ends, so its lower cap has
-- the shaft ABOVE it (8 placements); and the plaque base $30/$31 is a
-- pilaster's foot when the shaft is above it (68) but a statue's
-- plinth bottom when the cap $15/$16 is (47).
when_above = {
[21] = { { above = { 5 }, class = "bookcase" } },
[22] = { { above = { 6 }, class = "bookcase" } },
[48] = { { above = { 5 }, class = "bookcase" } },
[49] = { { above = { 6 }, class = "bookcase" } },
},
-- The vacated rows behind a collapsed wall take the cell ABOVE the
-- run rather than the default hidden floor: here that is more rock
-- face on ROUTE_23 and the plateau's paving or grass on INDIGO_
-- PLATEAU, so the wall reads as set INTO the terrace instead of
-- standing in front of a trench of synthesized ground.
bookcase_backfill = "above",
-- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
-- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
-- IS the statue's plinth, the artist having drawn the same stone
-- twice. Raising it would carry every statue standee up to 48px and
-- break the very match the cliff height was chosen for.
-- $05/$06 is NOT here: it moved to `bookcase` above, and a tile
-- listed in two groups resolves by whichever `pairs` order wins --
-- half the pilasters kept the shaft as `wall`, which broke the run
-- and left their caps as isolated 8px stubs. One group per tile.
wall = { 21, 22, 48, 49 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
@@ -2845,146 +3231,5 @@ return {
slab = 4, frontEave = 4, ledge = nil,
},
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
wall = { 3,
13, 14, 15,
21, 22, 48, 49, 5, 6,
32, 33, 46, 47 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
}
+360
View File
@@ -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
+137
View File
@@ -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
+193
View File
@@ -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
+187
View File
@@ -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
+201
View File
@@ -0,0 +1,201 @@
-- Leaving a battle: the fade the way back to the map never had.
--
-- Going IN is a whole production -- one of the original's eight wipes, picked by
-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a
-- hard cut: BattleState:finish pops itself and the map is simply THERE on the
-- next frame. On the flat battle screen that is a cut between a white field and
-- a tile map, which the original got away with. In this mode it is a cut between
-- a placed camera looking across an arena and a diorama looking down on a
-- walking player, and a jump that big reads as a glitch rather than as an edit.
--
-- So the battle fades out, closes behind the black, and the map fades up out of
-- it. The timing is a transitions record this mod registers rather than a
-- constant in here, so it is retunable in data like the engine's own eight.
--
-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a
-- vanilla battle keeps the cut it always had. While the mode IS on, every battle
-- gets it -- including one that found no arena and drew on the flat battle
-- screen -- because what is being smoothed over is the return to the MAP, and
-- the map is a diorama either way.
--
-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws
-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED
-- composite in a wrap around Renderer:endFrame paints it -- after the world
-- blit, after the letterbox, after the UI blit, which is the only point where a
-- single rect covers everything on screen at once.
--
-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted
-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that
-- borrowed it would darken the arena and leave the battle's text box sitting
-- bright on top of the black -- and the letterbox bars of a flat battle screen,
-- painted by the renderer's clear before any state draws, would not darken at
-- all.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleExit = {}
BattleExit.__index = BattleExit
-- The battle underneath keeps drawing while this is up -- what fades is its own
-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so
-- nothing the battle does can outrun the fade either.
BattleExit.isOpaque = false
-- The registered record's id, and the fallback timing if it is missing (a
-- headless caller, or a total conversion that dropped the namespace). Per HALF,
-- matching the engine's warp fade, so the whole edit is 24 frames.
BattleExit.ID = "voxel_battle_exit"
BattleExit.FRAMES = 12
-- The fade in progress, or nil. Kept here rather than on the state so the
-- endFrame wrap has one place to look and nothing can go stale the frame after
-- the state leaves the stack.
local live = nil
-- How black the composite is this frame: 0 on the battle's last live frame, 1 at
-- the cut, 0 again once the map is up. nil when no fade is running, which is
-- every other frame the game ever draws.
function BattleExit.veil()
if not live then return nil end
-- A fade can be taken off the stack by something that is not the fade: a
-- script or a shot driver popping down to the overworld, a state teardown.
-- Then it is not running, whatever its counter says -- and a veil left behind
-- would black the game out for good, because nothing is going to fade it back
-- in. Checked here rather than trusted, because this is the one place the
-- answer is used. The walk only happens while a fade is live.
local stack = live.game and live.game.stack
local states = stack and stack.states
local onStack = false
for i = #(states or {}), 1, -1 do
if states[i] == live then onStack = true break end
end
if not onStack then live = nil; return nil end
local a = live.t / live.frames
if live.phase == "in" then a = 1 - a end
return math.max(0, math.min(1, a))
end
local function framesFor(game)
local records = game and game.data and game.data.transitions
local record = records and records[BattleExit.ID]
local frames = record and record.frames
if type(frames) == "number" and frames > 0 then return frames end
return BattleExit.FRAMES
end
function BattleExit.new(game, battle, onMidpoint)
return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint,
frames = framesFor(game), t = 0, phase = "out" },
BattleExit)
end
-- Push the fade over the battle it is closing.
function BattleExit.start(battle, onMidpoint)
local game = battle.game
local self = BattleExit.new(game, battle, onMidpoint)
live = self
game.stack:push(self)
return self
end
function BattleExit:update()
self.t = self.t + 1
if self.t < self.frames then return end
self.t = 0
if self.phase == "in" then
live = nil
self.game.stack:pop()
return
end
-- ------- the cut, at full black
--
-- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while
-- this fade is up that is the fade -- so a fade that stayed would eat the
-- battle's own pop and leave the battle running underneath, finished but
-- still on the stack. Popping ourselves hands the top back to the battle so
-- its pop lands on itself.
self.phase = "in"
local stack = self.game.stack
stack:pop()
if self.onMidpoint then self.onMidpoint() end
if stack:top() == self.game.overworld then
stack:push(self) -- and the map comes up out of it
return
end
-- We are not going back to the map after all. Either the battle did not
-- actually leave -- finish() can be a false start, and wanted() mirrors the
-- one the engine has today -- or something else took the screen on the way
-- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns
-- the transition from here, so this one ends at the cut instead of fading in
-- over the top of it. The flag goes back too, so a second finish() that does
-- leave gets its own fade.
live = nil
if self.battle then self.battle.dramaticShapeLeaving = nil end
end
-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a
-- fault, and runnable on this machine. A function on the table rather than an
-- inline call so a driver or a headless test can pin it -- the test harness has
-- no depth buffer, where the honest answer is no on every rung.
function BattleExit.modeOn()
return require("src.render.Pipelines").eligible("voxel") and true or false
end
-- Whether this ending gets the fade.
function BattleExit.wanted(battle)
local game = battle and battle.game
if not (game and game.stack) then return false end
-- finish() is not always the end: an unpaid PAY DAY prints its takings and
-- comes back through here a moment later (BattleState:finish's first branch).
-- Mirrored read-only, so the fade starts on the call that really leaves rather
-- than fading to black and snapping back for one more message.
if battle.payDay and battle.result == "win" then return false end
return BattleExit.modeOn()
end
-- ------- engine seams
--
-- Two wraps, each idempotent so a hot reload cannot stack them.
function BattleExit.install()
local BattleState = require("src.battle.BattleState")
if not BattleState.dramaticShapeExitHook then
local inner = BattleState.finish
-- The one place a battle ends. Wrapped rather than listened for: the
-- battle.ended event is emitted AFTER the pop, and by then the battle
-- screen is gone and there is nothing left to fade out.
function BattleState:finish()
if self.dramaticShapeLeaving or not BattleExit.wanted(self) then
return inner(self)
end
self.dramaticShapeLeaving = true
BattleExit.start(self, function() inner(self) end)
end
BattleState.dramaticShapeExitHook = true
end
local Renderer = require("src.render.Renderer")
if not Renderer.dramaticShapeExitHook then
local inner = Renderer.endFrame
function Renderer:endFrame(zones, worldZones)
inner(self, zones, worldZones)
local a = BattleExit.veil()
if not a or a <= 0 then return end
-- The composite is on the screen by now, in LOVE units, so one rect over
-- the window darkens the world, the letterbox bars, the text box and
-- anything a present pass put on top, all by the same amount. Left to
-- last on purpose: this is a shutter closing on the finished frame, not a
-- layer inside it.
local w, h = love.graphics.getDimensions()
love.graphics.setColor(0, 0, 0, a)
love.graphics.rectangle("fill", 0, 0, w, h)
love.graphics.setColor(1, 1, 1, 1)
end
Renderer.dramaticShapeExitHook = true
end
end
return BattleExit
+408
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
-- 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: the pic is read
-- back once, the transparent region OUTSIDE the figure is flood-filled from
-- the border, and every transparent pixel the flood could not reach -- every
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
-- untouched, so the mon still cuts cleanly against the world; only its
-- insides stop being see-through.
--
-- 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
-- Mark every transparent pixel reachable from the border. That set is the
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
--
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
-- pixels and a keyed-out background is most of them, which is a deeper call
-- chain than is worth risking for no gain.
local function markOutside(data, w, h)
local outside = {}
local stack, top = {}, 0
local function push(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return end
local key = y * w + x
if outside[key] then return end
local _, _, _, a = data:getPixel(x, y)
if a > CUT then return end
outside[key] = true
top = top + 1
stack[top] = key
end
for x = 0, w - 1 do
push(x, 0)
push(x, h - 1)
end
for y = 0, h - 1 do
push(0, y)
push(w - 1, y)
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 outside = markOutside(data, w, h)
local fill = BattlePics.FILL
local changed = false
for y = 0, h - 1 do
local row = y * w
for x = 0, w - 1 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
+494
View File
@@ -0,0 +1,494 @@
-- 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,
}
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
View File
@@ -427,6 +427,18 @@ local function runGeometry(map, bodyOnly, masks, sink)
s = nil
end
-- Under the TREES fill the border wall is MODELLED or it is not there
-- (see Structures' hullRingOnly): a ring cell nothing claimed would
-- be a flat-topped box standing beside carved trunks, which reads as
-- a painted-on plateau rather than forest. Structures already stops
-- the ring at the carve distance; this catches the odd cell inside it
-- that the 2x2 grouping could not take -- a canopy whose partners
-- fall outside the shortened ring is left unclaimed, and one strip of
-- boxes along an edge is the whole artefact this avoids.
if not inBody and S.hideBareRing and not S.skip[k] then
s = nil
end
if s and S.skip[k] then
-- an object stands here; paint its synthesized ground and let the
-- prebuilt prism quads (appended below) carry the art
@@ -801,6 +813,33 @@ local function buildFlowerMesh(map)
return quadsMesh(Structures.forMap(map).flowerQuads)
end
-- Authored FIGURES (a person drawn into furniture) as one mesh each, in
-- the card's own local space -- because each one is placed by its own
-- matrix at draw time, leaned back by the camera pitch exactly like a
-- character card (VoxelScene). A figure baked into the terrain mesh could
-- not lean, and a shared mesh could not carry per-figure placement.
--
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
-- none, so the loop that draws them is shorter than the terrain's.
local function buildFigureMeshes(map)
local out = {}
for _, f in ipairs(Structures.forMap(map).figures or {}) do
local mesh = quadsMesh(f.quads)
if mesh then
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
end
end
return out
end
-- Figure lists hold their meshes one level down, so the generic slot
-- release cannot reach them.
local function releaseFigures(list)
for _, f in ipairs(type(list) == "table" and list or {}) do
if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end
end
end
-- Replace a cached slot, releasing whatever mesh it held.
local function swapSlot(c, slot, mesh)
local old = c[slot]
@@ -825,6 +864,8 @@ local function releaseEntry(c)
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
releaseFigures(c.figures)
c.figures = nil
c.stale = nil
end
@@ -863,18 +904,23 @@ end
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
if c.grass == nil or c.flowers == nil or c.figures == nil
or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
local okF, flowers = pcall(buildFlowerMesh, map)
local okX, figures = pcall(buildFigureMeshes, map)
if (gen[job.id] or 0) ~= job.gen then
if okG and grass and grass.release then pcall(grass.release, grass) end
if okF and flowers and flowers.release then
pcall(flowers.release, flowers)
end
if okX then releaseFigures(figures) end
return
end
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
releaseFigures(c.figures)
c.figures = (okX and figures) or false
if c.stale then c.stale.aux = nil end
end
local sink = newSink()
@@ -1022,6 +1068,14 @@ function ChunkMesher.flowers(map)
return c and c.flowers or nil
end
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
-- own leaning matrix at draw time, so they cannot share one mesh.
function ChunkMesher.figures(map)
local c = cache[map.id]
local list = c and c.figures
return (type(list) == "table") and list or nil
end
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
-- replacements cook, and each slot swaps as its build lands. This is
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
+499
View File
@@ -0,0 +1,499 @@
-- Voxel world mode: the day/night cycle -- one clock, and everything the
-- frame asks it.
--
-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
-- the player left is where the cycle picks up. Everything below is a pure
-- function of the clock, so the pinned settings and the running cycle can
-- never drift apart: DUSK is simply the cycle stopped at sunset.
--
-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
-- hanging in the southeast about 45 degrees up. That is the DAY setting and
-- the default, so a player who never touches the row sees the mod they
-- already had. From there the arc swings NORTH at both ends -- rising 70
-- degrees north of east, setting the mirror of that -- because the camera
-- looks north and the northern sky is the only sky it ever frames: a sun
-- that rose due east would light the world for ten minutes without once
-- being seen. Swung north, the disc stands in frame through dawn and dusk
-- (the hours worth looking at) and passes overhead-behind-the-camera
-- through midday, which is where a noon sun belongs.
--
-- THE MOON arcs entirely through the northern sky -- rising northeast, due
-- north at mid-night, setting northwest -- so it hangs over the diorama all
-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
-- softly south, away from it, at about two-thirds the sun's weight.
--
-- SHADOWS are the shear the light throws: direction opposite the body's
-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
-- a long shadow, not an infinite one), strength fading to nothing over the
-- last twelve degrees before the horizon so the handoff between sun and
-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
-- deliberately stays the noon bake: it is a subtle angle term baked into
-- every mesh, and rebaking the world's geometry per phase buys less than
-- the cast shadows, the sky and the tint already say.
--
-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
-- a cave at midnight is exactly as dark as a cave at noon, which is what a
-- room with no windows looks like. Map.isOutdoor is the same test the sky
-- already rests on; the caller passes its answer in (applyRig/tint).
--
-- Persistence: the running cycle's clock is written into the mod's own
-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
-- engine's save.writing event, and read back on save.loaded/created. A save
-- with no clock in it starts at noon.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local PaletteFX = require("src.render.PaletteFX")
local DayNight = {}
-- ------- the dial
DayNight.CYCLE = 1200 -- seconds around the whole dial
DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest
DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight
-- where the pinned settings stop the clock
DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
DayNight.KEY = "daytime"
DayNight.LABEL = "DAYTIME"
-- "sync" first: an unset or unreadable value follows the machine's own
-- clock, per the row's contract (ModSetting values[1] is the default) --
-- and forceSync below reaches for it by the same position.
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
{ "sync", "day", "night", "dusk",
"dawn", "cycle" },
{ "SYNC", "DAY", "NIGHT", "DUSK",
"DAWN", "CYCLE" })
-- The one writer for the FULL pin. While VOXEL sits on FULL the DAYTIME
-- row is off the menu with the rest of the rows the preset owns, and the
-- value is held HERE at SYNC -- the diorama preset's sky follows the clock
-- on the wall, whatever was chosen before. Called from every path that can
-- arrive at or act under FULL (main.lua: the preset itself, the rows hook,
-- the manager's options_changed), mirroring OverworldBattle.forceOG.
function DayNight.forceSync(game)
if DayNight.setting:get() ~= "sync" then
DayNight.setting:setIndex(1, game)
end
end
DayNight.clock = DayNight.T.day -- the running cycle's own position
-- ------- the two arcs
--
-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
-- south), elevations in degrees up from the ground plane.
-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
local NOON_KX, NOON_KZ = -0.85, -0.55
local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
+ NOON_KZ * NOON_KZ)))
local TH_RISE, TH_SET = -70, 250 -- north of east / north of west
local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
local EL_MOON = 40
DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height
DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight
DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press
DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set
-- disc PLACEMENT only: the true elevation would put the noon sun far above
-- any frame, so the arc the discs ride is squashed toward the horizon. The
-- shadows always use the true elevation.
DayNight.ELEV_SQUASH = 0.14
-- three-point arc: a at s=0, b at s=0.5, c at s=1
local function arc(a, b, c, s)
if s < 0.5 then return a + (b - a) * 2 * s end
return b + (c - b) * (2 * s - 1)
end
-- The body lighting the world at clock `t`: bearing and elevation in
-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
-- not the moon rising.
function DayNight.bodyAt(t)
t = t % DayNight.CYCLE
if t <= DayNight.DAY_LEN then
local s = t / DayNight.DAY_LEN
return arc(TH_RISE, TH_NOON, TH_SET, s),
EL_NOON * math.sin(math.pi * s), false
end
local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
return arc(TH_MRISE, TH_MMID, TH_MSET, s),
EL_MOON * math.sin(math.pi * s), true
end
-- The shadow shear that body throws: drift per pixel of height, opposite
-- the bearing, cot(elevation) long, clamped.
function DayNight.shearAt(t)
local th, el, moon = DayNight.bodyAt(t)
if el < 0.5 then el = 0.5 end
local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
end
-- How much shadow the light can press right now, 0..1 of the body's own
-- weight: full up high, gone at the horizon, so sunset hands off to
-- moonrise through a soft shadowless gap instead of snapping.
function DayNight.strengthAt(t)
local _, el = DayNight.bodyAt(t)
local s = el / DayNight.FADE_DEG
if s < 0 then return 0 end
return s < 1 and s or 1
end
-- ------- the palettes
--
-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
-- reads. Six bands, not four: twilight is the whole show here, and six rungs
-- of it is what keeps a sunset reading as a gradient rather than as stripes.
-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
-- after blending, which re-quantises onto it.
-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
-- through. Day's blue horizon and dusk's gold one are near-complements, and
-- a straight lerp between complements bottoms out in grey: mid-transition
-- the whole sky went the colour of dishwater, and gold-to-navy did the same
-- on the far side of sunset. So the evening bends through a golden hour
-- (horizon warming, zenith still blue -- late afternoon), and both edges of
-- the night bend through a violet civil twilight (rose horizon under a
-- violet sky -- the real colour of that half hour).
DayNight.PALETTES = {
day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
{ 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
{ 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
{ 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
{ 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
{ 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
{ 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
}
-- what the world's own colours are multiplied by, per phase (0..255)
DayNight.TINTS = {
day = { 255, 255, 255 },
golden = { 255, 232, 208 },
dawn = { 255, 216, 192 },
dusk = { 255, 192, 168 },
violet = { 184, 160, 200 },
night = { 120, 136, 192 },
}
-- the twilight glow around the low sun, and the discs' own four-shade
-- palettes (lightest first, so a display mode transforms them like any
-- other palette)
DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
{ 248, 144, 80 }, { 216, 96, 64 } }
DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
{ 168, 184, 208 }, { 120, 136, 168 } }
-- The dial as keyframes: a repeated name is a plateau, a change is a
-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
-- waypoint rather than straight across the grey between blue and gold. The
-- morning side needs no waypoint of its own: dawn's pinks into day's blues
-- share a family and blend clean.
local DIAL
local function dial()
if DIAL then return DIAL end
local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
DIAL = {
{ 0, "dawn" }, { B, "day" },
{ D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
{ D + B / 2, "violet" }, { D + B, "night" },
{ C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
}
return DIAL
end
-- Phase weights at clock `t`, off the dial above.
function DayNight.mix(t)
t = t % DayNight.CYCLE
local d = dial()
for i = 1, #d - 1 do
local a, b = d[i], d[i + 1]
if t >= a[1] and t < b[1] then
if a[2] == b[2] then return { [a[2]] = 1 } end
local u = (t - a[1]) / (b[1] - a[1])
return { [a[2]] = 1 - u, [b[2]] = u }
end
end
return { dawn = 1 }
end
-- back onto the 5-bit lattice after any blend
local function q8(v)
v = math.floor(v / 8 + 0.5) * 8
if v < 0 then return 0 end
return v > 248 and 248 or v
end
local function blend3(key, mix, fallback)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = key[name] or fallback
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
return { q8(r), q8(g), q8(b) }
end
-- The sky palette for clock `t`, blended between the phase palettes and
-- re-quantised to the lattice. Memoised per whole second: the answer only
-- moves as the cycle runs, and the cycle moves it slowly.
local palCache = { key = nil, pal = nil }
function DayNight.palette(t)
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if palCache.key == key then return palCache.pal end
local mix = DayNight.mix(t)
local pal = {}
for i = 1, #DayNight.PALETTES.day do
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.PALETTES[name][i]
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
pal[i] = { q8(r), q8(g), q8(b) }
end
palCache.key, palCache.pal = key, pal
return pal
end
-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
-- caller answers for where it is standing (see the header).
local tintCache = { key = nil, tint = nil }
local NEUTRAL = { 1, 1, 1 }
function DayNight.tint(outdoor, t)
if not outdoor then return NEUTRAL end
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if tintCache.key ~= key then
-- NOT re-quantised: this is a light level the shader multiplies by, not
-- a palette colour, and the lattice's 248 ceiling would make even noon
-- fractionally dim
local mix = DayNight.mix(t)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.TINTS[name] or DayNight.TINTS.day
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
tintCache.key = key
tintCache.tint = { r / 255, g / 255, b / 255 }
end
return tintCache.tint
end
-- The twilight glow: how strongly (0..1) and in what colour the sky warms
-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
function DayNight.glow(t)
t = t or DayNight.time()
local _, _, moon = DayNight.bodyAt(t)
if moon then return 0, nil end
local mix = DayNight.mix(t)
local amt = (mix.dawn or 0) + (mix.dusk or 0)
if amt <= 0 then return 0, nil end
return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
end
-- ------- the clock itself
local lastMode = nil
local function mode()
return DayNight.setting:get() or "day"
end
-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
-- fraction. A named seam rather than a bare os.date call, so the suite can
-- hand it a fixed hour.
function DayNight.hours()
local d = os.date("*t")
return d.hour + d.min / 60 + d.sec / 3600
end
-- SYNC: the machine's own time of day laid onto the dial. Local noon is
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
-- falls when the player's does.
function DayNight.syncTime()
return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
end
-- The effective time: the pin, the running clock under CYCLE, or the wall
-- clock under SYNC.
function DayNight.time()
local m = mode()
if m == "cycle" then return DayNight.clock end
if m == "sync" then return DayNight.syncTime() end
return DayNight.T[m] or DayNight.T.day
end
-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
-- (which ticks through battles and menus too, so night falls during a long
-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
-- the pin the player was just looking at: DUSK then CYCLE rolls on into
-- night rather than teleporting the sky.
function DayNight.update(dt)
local m = mode()
if m ~= lastMode then
if m == "cycle" then
-- from a pin, its time; from SYNC, wherever the real sky already was
DayNight.clock = DayNight.T[lastMode]
or (lastMode == "sync" and DayNight.syncTime())
or DayNight.clock
end
lastMode = m
end
if m == "cycle" and dt and dt > 0 then
DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
end
end
-- The clock the RIG runs on: quantised, so the shadow map redraws a few
-- times a minute as the sun crawls rather than every frame.
DayNight.STEP = 2
function DayNight.rigTime()
local t = DayNight.time()
return math.floor(t / DayNight.STEP) * DayNight.STEP
end
-- ------- what the frame reads
-- Point the shared light rig at the clock -- or at noon, indoors. This
-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
-- sunDark uniform), so no draw path changes to follow the sun; they follow
-- the rig, and the rig follows the clock.
function DayNight.applyRig(outdoor)
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local t = outdoor and DayNight.rigTime() or DayNight.T.day
local kx, kz, moon = DayNight.shearAt(t)
ShadowMap.KX, ShadowMap.KZ = kx, kz
Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
return t
end
-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
-- caller that sets its own alpha (the battle arena) and should still lose
-- its shadows to a sunset.
function DayNight.shadowScale(outdoor, t)
if not outdoor then return 1 end
t = t or DayNight.rigTime()
local _, _, moon = DayNight.bodyAt(t)
local s = DayNight.strengthAt(t)
return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
end
-- The disc to hang in the sky, or nil when the body is set or behind the
-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
-- is; the caller projects it through its own camera.
function DayNight.body(t)
t = t or DayNight.time()
local th, el, moon = DayNight.bodyAt(t)
if el < -2 then return nil end
local e = math.rad(el * DayNight.ELEV_SQUASH)
local b = math.rad(th)
return {
dx = math.cos(b) * math.cos(e),
dy = math.sin(e),
dz = math.sin(b) * math.cos(e),
moon = moon,
}
end
-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun
-- or moon to see, so the shadow rig stays the mod's fixed noon light,
-- which is all that ever filtered through the leaves -- but not a sealed
-- room either: night still FALLS in them. Of everything the clock does,
-- exactly one thing reaches a canopy map: the hour's tint.
DayNight.CANOPY = { VIRIDIAN_FOREST = true }
function DayNight.isCanopy(map)
return (map and map.id and DayNight.CANOPY[map.id]) and true or false
end
-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky.
-- They come on through dusk (a lit window against a sunset is half the point
-- of having either), burn all night, and are mostly out again by dawn:
-- people wake before it is bright, they do not read at sunrise.
local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 }
function DayNight.windowLight(t)
local mix = DayNight.mix(t or DayNight.time())
local lit = 0
for name, w in pairs(mix) do
lit = lit + (LAMPS[name] or 0) * w
end
return lit
end
-- The period name for the engine's world.tod hook (map.palette ctx.tod,
-- music.select): the dominant phase, in the vocabulary day/night mods use.
local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
function DayNight.tod(t)
local mix = DayNight.mix(t or DayNight.time())
local best, bestW = "day", -1
for name, w in pairs(mix) do
if w > bestW then best, bestW = name, w end
end
return TOD[best] or "DAY"
end
-- ------- persistence
--
-- The clock rides the SAVE SLOT, not the options file: what time it is in
-- Kanto is a fact about that journey, like where the player is standing.
-- mod.save is the loader's per-mod bucket in save.modData, which persists
-- with the slot on its own -- writing the value is all there is to do.
DayNight.SAVE_KEY = "clock"
function DayNight.store()
local saveApi = V.mod and V.mod.save
if not (saveApi and saveApi.set) then return end
pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
end
function DayNight.restore()
local saveApi = V.mod and V.mod.save
local stored = nil
if saveApi and saveApi.get then
local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
if ok then stored = got end
end
-- no time set: it is day (the requirement, verbatim)
DayNight.clock = type(stored) == "number"
and stored % DayNight.CYCLE or DayNight.T.day
end
return DayNight
+174
View File
@@ -0,0 +1,174 @@
-- Voxel world mode: the glass in the windows, found rather than listed.
--
-- Buildings and doors in the overworld art carry small panes -- six texels
-- wide, framed in black, with a diagonal shine drawn in. This module finds
-- them by SHAPE in the tileset image itself: a border row of six black
-- texels, four or five rows of black-flanked non-black glass under it, and
-- a closing border row. No tile ids are hardcoded, so a total conversion
-- that draws its own windows in the same idiom gets glass for free, and art
-- with no windows gets an empty mask and costs nothing.
--
-- The scan slides at PIXEL granularity because the art does: the building
-- window sits a row down inside its tile, and the door's pane straddles a
-- 2x2 tile block entirely -- a per-tile matcher finds neither.
--
-- What the scan yields is a MASK TEXTURE the same size as the tileset
-- atlas: opaque white on glass texels, transparent everywhere else. Terrain
-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect),
-- so the scene shader can sample this mask with the SAME coordinates and
-- know, per fragment, whether it is drawing glass -- on any wall, at any
-- angle, in free-roam or a staged battle, with no geometry work anywhere.
-- The recoloured atlases (display modes, RED++) keep the tileset's layout,
-- so the alignment holds under every palette.
--
-- What the shader does with the answer (Voxel3D): by day a thin glint
-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving
-- the art under it -- and after dark the panes are LIT: the texel's own
-- shine pattern, warmed and brightened, exempt from the sun, the shadow
-- map and the hour's tint, as a window with a lamp behind it is.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local GlassMask = {}
-- pane geometry the scan accepts: six glass texels across, and this many
-- rows of them between the two border rows
GlassMask.GLASS_W = 6
GlassMask.ROWS = { 4, 5 } -- door pane, building pane
-- Whether a channel triple is the border black. The raw tileset art is the
-- four DMG greys, so black is genuinely zero; the threshold forgives a
-- rescaled asset without accepting the dark grey rung (85/255 = 0.33).
local function isBlack(r, g, b)
return r < 0.12 and g < 0.12 and b < 0.12
end
GlassMask._isBlack = isBlack -- named for the suite
-- Find every pane in an image, through a pure reader so the geometry is
-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based
-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels
-- (the border is the detector's evidence, not part of the answer).
function GlassMask.scan(getPixel, w, h)
local function black(x, y)
return isBlack(getPixel(x, y))
end
local function borderRow(x, y)
for c = 1, 6 do
if not black(x + c, y) then return false end
end
return true
end
local function glassRow(x, y)
if not (black(x, y) and black(x + 7, y)) then return false end
for c = 1, 6 do
if black(x + c, y) then return false end
end
return true
end
local want = {}
for _, n in ipairs(GlassMask.ROWS) do want[n] = true end
local rects = {}
for y = 0, h - 1 do
for x = 0, w - 8 do
if borderRow(x, y) then
local n = 0
while y + 1 + n < h and glassRow(x, y + 1 + n) do
n = n + 1
end
if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then
rects[#rects + 1] = { x = x + 1, y = y + 1,
w = GlassMask.GLASS_W, h = n }
end
end
end
end
return rects
end
-- ------- the runtime cache, one entry per tileset image
local cache = {} -- image path -> { rects, texture (or false) }
local function entry(tileset)
local path = tileset and tileset.image
if not path then return nil end
local hit = cache[path]
if hit then return hit end
local ok, data = pcall(Assets.imageData, path)
if not (ok and data) then
-- unreadable art is a verdict for the session, not a retry loop
cache[path] = { rects = {}, texture = false }
return cache[path]
end
local w, h = data:getDimensions()
local rects = GlassMask.scan(function(x, y)
return data:getPixel(x, y)
end, w, h)
local texture = false
if #rects > 0 and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage then
local built = pcall(function()
local mask = love.image.newImageData(w, h)
for _, r in ipairs(rects) do
for yy = r.y, r.y + r.h - 1 do
for xx = r.x, r.x + r.w - 1 do
mask:setPixel(xx, yy, 1, 1, 1, 1)
end
end
end
texture = love.graphics.newImage(mask)
texture:setFilter("nearest", "nearest")
end)
if not built then texture = false end
end
cache[path] = { rects = rects, texture = texture }
return cache[path]
end
-- The panes found in a tileset's art, as glass rects in atlas pixels.
function GlassMask.rects(tileset)
local e = entry(tileset)
return e and e.rects or {}
end
-- The mask texture for a tileset, or nil when it has no panes (or the art
-- is unreadable, or there is no GPU) -- callers bind the blank instead.
function GlassMask.texture(tileset)
local e = entry(tileset)
return (e and e.texture) or nil
end
-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask:
-- the scene shader always declares the sampler, and an unbound sampler is
-- a driver-dependent crash rather than a fallback.
local blank = nil
function GlassMask.blank()
if blank == nil then
local ok, img = pcall(function()
local data = love.image.newImageData(1, 1)
data:setPixel(0, 0, 0, 0, 0, 0)
return love.graphics.newImage(data)
end)
blank = (ok and img) or false
end
return blank or nil
end
-- Drop the GPU objects (window resize, hot reload). The rects survive --
-- they are a fact about the art -- but textures are rebuilt on demand.
function GlassMask.invalidate()
for _, e in pairs(cache) do
if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end
e.texture = false
end
cache = {}
blank = nil
end
return GlassMask
+936
View File
@@ -0,0 +1,936 @@
-- Overworld battles: fights that happen on the map you were standing on.
--
-- The engine's battle is a screen: a white field with two pics on it, pushed
-- over a frozen overworld that stops drawing. This turns that white field
-- into the world -- the same terrain the free-roam mode extrudes, shot from
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
-- animation, faint slide and text box is the engine's own, drawn in the
-- engine's own order. What changes is what is behind them, and where the two
-- pics stand.
--
-- The sequence, from the moment something picks a fight:
--
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
-- plays over an empty map and no bystander is left standing in the
-- arena shot
-- 2. the engine's own transition wipes the screen (untouched: it is the
-- right wipe, picked by the right three bits)
-- 3. the battle draws over a live, window-resolution render of the arena,
-- with each mon PINNED to the cell it is standing on, the camera
-- drifting slowly enough to read as parallax, and a depth-of-field pass
-- holding the slab of world the two of them occupy sharp
-- 4. the battle ends, the cast comes back, and the player is exactly
-- where they were standing
--
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
-- real warp would have to survive trainer sight-lines, post-battle
-- dialogue, the blackout path and every script that assumes the player is
-- where it left them -- and it would have to put them back afterwards.
-- Moving the camera buys the whole shot and owes nothing back.
--
-- The feature declines cleanly rather than half-working: no depth support,
-- no open ground on the map, the row switched off, or a mesh still building
-- all end at the same place, which is the battle screen the engine has
-- always drawn.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local BattleArena = V.require("BattleArena")
local BattleCam = V.require("BattleCam")
local BattleScene = V.require("BattleScene")
local BattleDOF = V.require("BattleDOF")
local BattleHud = V.require("BattleHud")
local BattlePics = V.require("BattlePics")
local Voxel3D = V.require("Voxel3D")
local ChunkMesher = V.require("ChunkMesher")
local OverworldBattle = {}
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
-- second, which is how the glyph flip is checked from a shot run. Read
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
-- diagnostic must never be the reason the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end
OverworldBattle.KEY = "battles"
OverworldBattle.LABEL = "3D-BTL"
-- On by default: a mod whose headline is "the world in 3D" should not need
-- the player to go and find the switch before the world shows up in a
-- battle. ON is first, so it is also what an unreadable stored value falls
-- back to.
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
OverworldBattle.LABEL,
{ true, false }, { "ON", "OFF" })
function OverworldBattle.enabled()
return OverworldBattle.setting:get() and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
-- wrong twice over: it is turned away from the camera that is looking at it,
-- and the back pics are a different, smaller drawing made for a slot the
-- player never really sees. So the player's side asks for the FRONT pic too,
-- through the engine's own pokemon.sprite hook -- the seam that exists for
-- exactly this, so no battle code has to be touched to get it.
--
-- Answered BEFORE a battle exists, because the battler is built before the
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
-- whether one on this map WOULD be -- the row is on, the 3D pass is
-- available, and the map has an arena -- which is the same question with the
-- same answer a moment later. Cached per map, because the arena search walks
-- the whole grid and this runs once per battler.
local staged = { mapId = nil, ok = false }
function OverworldBattle.wantsFront()
if not OverworldBattle.enabled() then return false end
if not Voxel3D.available() then return false end
-- required here rather than through the file's own helper: this runs
-- while a battler is being built, which is before that helper is defined
local g = require("src.core.Game")
local ow = g and g.overworld
if not (ow and ow.map and ow.player) then return false end
if staged.mapId ~= ow.map.id then
local ok, arena = pcall(BattleArena.find, ow.map,
ow.player.cellX, ow.player.cellY,
ow.player.surfing)
staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
end
return staged.ok
end
-- ------- where the engine's own pics stand
--
-- The GB draws the player's back pic with its feet on the text box at row 96
-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
-- points are the pics' FEET, they hold for every species at every scale (the
-- engine's placement helpers pin the bottom edge and the centre), and they
-- are what BattleCam is solved to put the two arena cells under.
--
-- Which makes the pin a subtraction: whatever the drift has done to the
-- camera this frame, each pic moves by its own cell's projected position
-- minus its anchor. At the middle of the drift that is zero.
OverworldBattle.ANCHOR = {
player = { 26, 96 },
enemy = { 124, 56 },
}
-- ------- how big a mon is
--
-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
-- keeps every pixel the artist drew, and the CAMERA is solved so that one
-- overworld square is that big on screen (see BattleCam). The mon fits its
-- tile because the tile was sized to the mon, not the other way round.
OverworldBattle.SLOT_W = { front = 56, back = 32 }
-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
-- the anchors above.
OverworldBattle.HUD_RECT = {
enemy = { 8, 0, 80, 32 },
player = { 72, 56, 88, 40 },
}
-- ------- the HUDs, out at the window's own edges
--
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
-- whole of it. That left both HUD blocks huddled together in the middle of the
-- frame with map showing on either side of them, which reads as a Game Boy
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
--
-- So each block is snapped to its own side: the foe's to the left edge of the
-- window, the player's to the right. Nothing about either block changes -- same
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
-- of the GB screen there is nowhere to go and the snap is a no-op.
--
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
-- canvas and everything outside it is clipped away. So the layer is rendered to
-- a texture and composited into the WORLD image instead, which is the one
-- surface in this mode that covers the whole window.
-- The rows each block is cut out of, full width. Generous on purpose:
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
-- further than the panel does, and the pokeball rows and the safari ball count
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
-- outside rows 0-96, and the two bands split that between them.
OverworldBattle.HUD_BAND = {
enemy = { 0, 0, 160, 48 },
player = { 0, 48, 160, 48 },
}
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
-- glass is cut to, plus the x its band is blitted at.
--
-- The foe's panel starts at the window's left edge and the player's ends at the
-- right one. The vertical is untouched, so both stay on the rows the GB put
-- them on. A band's own origin sits outside the window by the panel's inset --
-- the couple of pixels a HUD shake can push past the edge are clipped there,
-- which is the whole cost of the snap and is invisible.
function OverworldBattle.snapRects(shot)
local s = shot.scale
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
local ex = -e[1] * s -- foe: panel's left edge to 0
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
local rects = {
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
}
return rects, { enemy = ex, player = px }
end
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
-- not nest.
local session = nil
local function game()
return require("src.core.Game")
end
-- Whether this frame's HUDs went out to the window's edges instead of being
-- drawn in the GB frame. False whenever the composite could not be made, which
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
local function snapped()
return (session and session.snapped) and true or false
end
-- Put the map's cast back. Both lists are handed back by identity, so
-- anything that captured one before the battle still sees the same table.
local function restoreCast()
if not (session and session.state) then return end
if session.entities then session.state.entities = session.entities end
if session.ghosts then session.state.ghosts = session.ghosts end
session.entities, session.ghosts = nil, nil
end
-- Cull them. The player stays -- they are not an NPC, they are who the
-- battle belongs to, and Fly/surf animations and the save's own capture read
-- state.player through this list.
--
-- Only the DRAW lists are touched, and only while the overworld is frozen
-- underneath a battle: StateStack updates the top state alone, so nothing
-- walks, wanders, triggers or collides against a list that is short for
-- these frames. The originals go back at battle.ended.
local function cullCast(state)
session.entities = state.entities
session.ghosts = state.ghosts
state.entities = { state.player }
state.ghosts = {}
end
-- ------- one right battle layout
--
-- Everything this file composes is measured in the GB's own 160x144 frame: the
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
-- the frosted panels are cut to, and the full-frame white intercepted to let
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
-- -- the mons would stand where no camera was solved for them, and the panels
-- would land beside the HUDs they are supposed to be under.
--
-- So while a fight can be staged on the map there is one right answer, and it
-- is SET rather than worked around. The engine reads the option live
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
-- top state for its surface the same way), so writing it here lands on the
-- battle being pushed as well as every one after it.
--
-- This is the last line rather than the first: the OPTIONS menu takes the row
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
-- player is never offered a switch that gets reverted under them. What reaches
-- here is a value that arrived some other way -- a save written before the mod
-- was installed, the mod manager's own page, another mod.
function OverworldBattle.forceOG(g)
g = g or game()
local opts = g and g.save and g.save.options
if not opts or opts.battleLayout ~= "wide" then return false end
opts.battleLayout = "og"
if g.writeOptions then pcall(g.writeOptions, g) end
return true
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
-- battle it always did, cast and all.
function OverworldBattle.begin(state, battle)
OverworldBattle.finish()
if not OverworldBattle.enabled() then return false end
if not (state and state.map and state.player) then return false end
if not Voxel3D.available() then return false end
local ok, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
if not (ok and arena) then return false end
-- the fight is staged from here on, so the layout it is composed for is not
-- optional any more (see forceOG)
OverworldBattle.forceOG()
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
BattleCam.reset()
return true
end
-- The fallback entry point: a battle that arrived without going through the
-- overworld's own pushBattle (a link battle, a script pushing a BattleState
-- directly). Nothing visible depends on the cull for those -- the wipe has
-- already been and gone -- but the arena still has to be picked.
function OverworldBattle.ensure(battle)
if session then
-- a battle pushed through the overworld reaches begin() before it is
-- built far enough to draw; battle.started is where it is finished
if battle and not session.battle then session.battle = battle end
return
end
local g = game()
local ow = g and g.overworld
if ow and ow.map then OverworldBattle.begin(ow, battle) end
end
-- The arena this battle is staged on, or nil. Read by the shot driver so a
-- screenshot can be labelled with the ground it was taken on.
function OverworldBattle.arena()
return session and session.arena or nil
end
function OverworldBattle.finish()
if not session then return end
restoreCast()
session = nil
Voxel3D.camera = nil
end
-- ------- per-frame
--
-- Driven from the voxel pipeline's update hook, which the engine ticks every
-- frame regardless of which state is on top -- including the frames the
-- transition wipe covers, which is what gets the arena's meshes built before
-- the first battle frame needs them.
--
-- The scene is rendered HERE rather than inside the battle's draw, because
-- update runs with no canvas bound: a 3D pass that binds a depth target and
-- unbinds to the screen when it is done cannot do that in the middle of
-- someone else's frame without putting the frame back itself.
function OverworldBattle.update(dt)
if not session then return end
local g = game()
local top = g and g.stack and g.stack:top()
local ow = g and g.overworld
-- A battle that ended without saying so (a script tearing the state down,
-- a path that never emits battle.ended) would otherwise leave the cast
-- culled for good. Armed only once something has actually covered the
-- overworld, because begin() runs while the overworld is still on top.
if top ~= nil and top ~= ow then
session.armed = true
elseif session.armed then
OverworldBattle.finish()
return
end
BattleCam.update(dt)
-- the battle only exists once it has been pushed; a session opened at
-- pushBattle time has it, one opened from battle.started was handed it
session.battle = session.battle or (top ~= ow and top or nil)
-- the world pass is hidden behind the battle, so mesh builds get the wide
-- slice: nothing visible can hitch on them
ChunkMesher.pump(true)
-- The mons' textures are rendered HERE, with no canvas bound, for the same
-- reason the scene is: the pics layer binds its own targets, and doing that
-- inside somebody else's frame means putting the frame back afterwards.
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
if not okTex then textures = nil end
session.token = (session.token or 0) + 1
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
textures, session.token)
if not ok then
-- One failure retires the arena for THIS battle and nothing else: the
-- battle screen carries on as the engine's own, the free-roam pipeline
-- this runs inside keeps rendering the overworld, and the next battle
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
-- guard, which retires a pipeline for the session.
session.shot = nil
session.snapped = false
session.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
session.snapped = false
if shot and shot.canvas then
-- the depth of field is measured off the two marks: the slab in focus is
-- the one the mons are standing in, at whatever the drift has done to
-- where that lands
local y1 = shot.ly + shot.player[2] * shot.scale
local y2 = shot.ly + shot.enemy[2] * shot.scale
local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
focusY, band, range)
if okDof and blurred then shot.canvas = blurred end
-- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
-- so a panel over a blurred far field is frosted from what is actually
-- behind it
pcall(BattleHud.build, shot.canvas)
-- and then the HUDs go ON that backdrop, snapped out to the window's own
-- edges (snapHUDs). Here rather than in the battle's draw for the same
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
end
end
session.shot = shot
end
-- The finished shot for this frame, or nil when there is none and the battle
-- should draw the way it always did.
function OverworldBattle.shot()
if not session or session.broken then return nil end
local s = session.shot
if s and s.canvas then return s end
return nil
end
function OverworldBattle.invalidate()
BattleDOF.invalidate()
BattleHud.invalidate()
BattlePics.invalidate()
end
-- ------- the battle screen's background
--
-- BattleState opens by filling 160x144 white -- that fill IS the battle's
-- background, and in the colorized pipeline it is also the BG canvas's clear
-- (nothing else clears it, so skipping it outright would ghost last frame).
-- So for the length of one draw, that one call is intercepted: on the two
-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
-- composites the HUD and the text box over the arena and leaves the empty
-- field showing it; on the screen it is simply dropped, because the UI canvas
-- has already been cleared transparent for the world to show through.
--
-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
-- move-animation flash (which is white at 0.85) all pass through untouched.
--
-- This is a shim over love.graphics and it is the one invasive thing here,
-- so it is scoped as tightly as it can be: installed around a single call,
-- removed on the way out including on error, and never live outside a battle
-- frame this mode is drawing.
local function withoutBackgroundFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, x, y, w, h, ...)
if mode == "fill" and x == 0 and y == 0
and w == BattleScene.GB_W and h == BattleScene.GB_H then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 then
-- Two different full-frame whites, both replaced rather than drawn.
--
-- OPAQUE is the battle's background, and on the offscreen canvases it
-- doubles as their clear, so there it becomes a transparent one.
--
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
-- flash; over a world it whites out the map, the HUD and the text box
-- together. BattleScene puts it back on the mons alone.
if a > 0.99 then
local target = g.getCanvas()
if target ~= nil
and (target == battle.bgCanvas or target == battle.waveCanvas) then
g.clear(0, 0, 0, 0)
end
end
return
end
end
return rectangle(mode, x, y, w, h, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the mons, as textures for the 3D pass
--
-- The two Pokemon are not composited over the world any more: they are quads
-- standing in it (see BattleBillboard). What that needs from the battle
-- screen is a TEXTURE per side -- and the honest way to get one is to let the
-- engine draw its own pics layer, unchanged, into a canvas.
--
-- So the layer is rendered twice, once per side, with the other side
-- falsified out of existence by nulling exactly the fields its branches
-- test. Everything the engine does to a pic comes along for free that way:
-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
-- faint slide, the damage blink, the squish, every SE displacement. None of
-- it is reimplemented and none of it can drift.
--
-- Two things are forced during that render. The scale, to 1, so the texture
-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
-- placement, so the pic lands centred on a known column with its feet on a
-- known row. That known point is what the quad is then hung from.
local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline
local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
-- Which side is being rendered, or nil. The placement wrappers read it.
local texturing = nil
local texCanvas = {}
local innerPics = nil -- captured by install()
local innerHUDs = nil -- likewise, for the snapped HUD layer
local function texCanvasFor(side)
local c = texCanvas[side]
if c then return c end
local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
BattleScene.GB_H, { dpiscale = 1 })
if not ok then return nil end
made:setFilter("nearest", "nearest")
texCanvas[side] = made
return made
end
-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own
-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or
-- not-yet-sent-out mon simply has no billboard this frame.
local function sideVisible(battle, side)
if side == "enemy" then
if battle.showEnemyTrainer and battle.trainerPic then return true end
return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden
and not battle.enemySendingOut
and not battle:fxHidden(battle.enemy)) and true or false
end
if battle.showPlayerBack and battle.playerBackPic then return true end
local hide = battle.safari or battle.demo
return (battle.player and battle.player.sprite and not hide
and not battle.sendingOut
and not battle:fxHidden(battle.player)) and true or false
end
local OFF = {
enemy = { player = false, showPlayerBack = false },
player = { enemy = false, showEnemyTrainer = false },
}
-- Render one side's pics layer into its canvas and report where the pic's
-- feet ended up, in canvas coordinates.
function OverworldBattle.sideTexture(battle, side)
if not (innerPics and battle) then return nil end
if not sideVisible(battle, side) then return nil end
local canvas = texCanvasFor(side)
if not canvas then return nil end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
-- The pic-window scissors are in the battle screen's fixed coordinates and
-- would clip a pic that has been moved to the middle of its own canvas.
-- There is nothing here for them to protect -- no HUD, no text box, just
-- the one pic -- so they are switched off for the render.
local setScissor, intersectScissor = g.setScissor, g.intersectScissor
local getScissor = g.getScissor
g.setScissor = function() end
g.intersectScissor = function() end
g.getScissor = function() return nil end
local saved = {}
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
texturing = side
local ok, err = pcall(function()
g.setCanvas(canvas)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
innerPics(battle, 0, 0, 0)
end)
texturing = nil
for k in pairs(OFF[side]) do battle[k] = saved[k] end
g.setScissor, g.intersectScissor, g.getScissor =
setScissor, intersectScissor, getScissor
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then error(err, 0) end
local ax, ay = TEX_AX, TEX_AY
local trainer = false
-- The intro trainer pic draws itself straight into its own 7x7 slot rather
-- than through the placement helpers, so it is hung from that slot instead.
if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then
ax, ay, trainer = TRAINER_AX, TRAINER_AY, true
elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then
trainer = true
end
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
end
-- Whether the hit flash is showing this frame.
--
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
-- decision there (a counter plus the frame parity that makes it flicker) and
-- there is no seam that reports it. Read-only, so the worst a future engine
-- change can do is flash on a frame the engine would not have.
function OverworldBattle.flashing(battle)
local fx = battle and battle.fx
if not (fx and fx.flash and fx.flash > 0) then return false end
return (battle.frame or 0) % 4 < 2
end
-- Both sides, or nil when neither has anything to show.
function OverworldBattle.textures(battle)
if not battle then return nil end
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
out.flash = OverworldBattle.flashing(battle)
return out
end
-- ------- engine seams
--
-- Four wraps, each idempotent so a hot reload cannot stack them.
function OverworldBattle.install()
local OverworldState = require("src.world.OverworldController")
if not OverworldState.dramaticShapeBattleHook then
local inner = OverworldState.pushBattle
-- The one place the overworld starts a battle, and it runs BEFORE the
-- transition is pushed -- which is what lets the cull happen off-screen
-- and the wipe play over a map with nobody on it.
function OverworldState:pushBattle(battle)
pcall(OverworldBattle.begin, self, battle)
return inner(self, battle)
end
OverworldState.dramaticShapeBattleHook = true
end
local BattleState = require("src.battle.BattleState")
if BattleState.dramaticShapeBattleHook then return end
-- Integer scales only. The camera is solved to make one overworld square
-- exactly big enough for a pic at its own integer scale (see BattleCam), so
-- the fit never has to come out of the pixels -- and a species override or
-- a battle_sprite_scales entry that asks for 1.7x would undo that and
-- resample the sprite into mush. Rounded rather than refused, so such a mod
-- still gets the bigger or smaller mon it asked for, on the pixel grid.
local innerScale = BattleState.resolveBattleScale
function BattleState.resolveBattleScale(data, side, path, species)
local base = innerScale(data, side, path, species)
-- 1:1 into the billboard texture: the artwork's own pixels, with the
-- quad's world size doing every bit of the scaling. Anything else would
-- resample the sprite twice -- once into the texture and again on the way
-- to the screen -- and a twice-resampled Gen 1 pic is mush.
if texturing then return 1 end
if not OverworldBattle.shot() then return base end
return math.max(1, math.floor((tonumber(base) or 1) + 0.5))
end
-- Keyed-out whites inside a pic used to be filled by the white field
-- behind it. There is a world back there now, so they are filled here
-- instead -- see BattlePics, which puts the paper back without touching
-- the silhouette.
local innerPic = BattleState.picImage
function BattleState:picImage(img)
local out = innerPic(self, img)
if not OverworldBattle.shot() then return out end
return BattlePics.filled(out)
end
-- While a billboard texture is being rendered both pics are put in the same
-- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad
-- has one anchor to hang from whichever side and whichever species it is
-- carrying. Outside that render both helpers answer exactly as they always
-- did.
local innerBack = BattleState.backPlacement
function BattleState.backPlacement(w, h, pad, padL, scale)
local x, y, s = innerBack(w, h, pad, padL, scale)
if not texturing then return x, y, s end
return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s
end
local innerFront = BattleState.frontPlacement
function BattleState.frontPlacement(ex, ey, w, h, scale)
local x, y, s = innerFront(ex, ey, w, h, scale)
if not texturing then return x, y, s end
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
end
local innerDraw = BattleState.draw
function BattleState:draw()
local shot = OverworldBattle.shot()
-- AskName blanks the field on purpose (the nickname prompt is meant to
-- sit on nothing); leave that one alone.
if not shot or self.blankForAskName then
-- nil, not false: the class default is inherited again, so a battle
-- that loses its arena mid-fight goes back to white voids
self.letterboxWhite = nil
self.dramaticShapeShot = nil
return innerDraw(self)
end
self.dramaticShapeShot = shot
-- The world reaches the screen through the seam a render pipeline's
-- finished world image already uses: one window-resolution canvas,
-- blitted a pixel to a pixel, with the 160x144 UI canvas composited over
-- it in the classic letterbox afterwards. That is what makes the backdrop
-- as crisp as the free-roam diorama while the pics and text stay GB art.
local renderer = game().renderer
if renderer and renderer.setWorldOverride then
renderer:setWorldOverride(shot.canvas)
end
-- beginFrame clears the UI canvas white for an opaque state; the world is
-- under it now, so clear it back to nothing and let it through. Safe to
-- do here: an opaque battle is the lowest state drawn, so nothing has
-- drawn into this canvas yet.
love.graphics.clear(0, 0, 0, 0)
-- the white letterbox exists so the window matches the white battle
-- canvas; there is a world out to the window edges now
self.letterboxWhite = false
OverworldBattle.drawHudPanels(self)
withoutBackgroundFill(self, innerDraw)
end
-- The mons are geometry standing on the map now, drawn in the 3D pass
-- before this screen is composited at all, so the flat pics layer has
-- nothing left to do here. Skipped rather than left to draw underneath, or
-- every Pokemon would appear twice: once on its tile and once in its slot.
innerPics = BattleState.drawPicsLayer
function BattleState:drawPicsLayer(slide, sx, sy)
if self.dramaticShapeShot then return end
return innerPics(self, slide, sx, sy)
end
-- Move animations are authored against the pics' fixed slots, and a single
-- animation reaches across both sides, so there is no per-side offset to
-- give them. They ride the average, which is where the pair's centre went
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
-- aimed at instead of drifting off it.
local innerAnim = BattleState.drawAnimLayer
function BattleState:drawAnimLayer(colorized)
local shot = self.dramaticShapeShot
if not shot then return innerAnim(self, colorized) end
-- Move animations are authored against the pics' old fixed slots, and one
-- animation reaches across both sides, so there is no per-side offset to
-- give them. They ride to where the PAIR went: the midpoint of the two
-- mons' projected positions, less the midpoint of the slots they used to
-- sit in. A hit still lands on the mon it is aimed at.
local a = OverworldBattle.ANCHOR
local dx = (shot.enemy[1] + shot.player[1]) / 2
- (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + shot.player[2]) / 2
- (a.enemy[2] + a.player[2]) / 2
love.graphics.push()
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
local ok, err = pcall(innerAnim, self, colorized)
love.graphics.pop()
if not ok then error(err, 0) end
end
-- The engine's flash has a SECOND half, and it is the one that reaches the
-- menu. Beside the white rectangle (dropped above) the flash moves are
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
-- colorized pipeline applies in drawZonePass to the WHOLE background
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
-- meant for the two mons washed the menu out with them.
--
-- The fade is left switched on for the pics, which read it through
-- picImage, and switched off for the zone pass alone. So the mons flash
-- and the furniture around them does not.
--
-- The zone pass has a SECOND thing it paints, and this is the one that
-- reads as the menu box flashing. A screen shake makes it fill every zone
-- with the zone's own color 0 before it draws the offset copy -- the
-- hardware showing empty BG in the strip the shake vacated. On a white
-- battle field that fill is invisible; over a world it is an opaque white
-- sheet across the whole frame, and since a shake program alternates
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
-- switches on and off a few times a second. It is dropped: the background
-- here is the map, so what the shake vacates should show the map.
local innerZone = BattleState.drawZonePass
function BattleState:drawZonePass(src, sx, sy)
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
-- shadow the method on the instance for this call only; putting the
-- field back to whatever it was (normally nil) lets the class method be
-- found again
local had = rawget(self, "activeBgp")
self.activeBgp = function() return nil end
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
-- the pass draws no other rectangle; the shake still shifts the copy
if mode == "fill" then return end
return rectangle(mode, ...)
end
local ok, err = pcall(innerZone, self, src, sx, sy)
g.rectangle = rectangle
self.activeBgp = had
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
-- the colorized pipeline this lands in the grayscale BG canvas, where
-- white IS shade 0 and the zone pass then colours it like every other
-- lightest-shade surface. One rule, both pipelines.
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
-- Normally the HUDs have already been drawn this frame, snapped out to the
-- window's edges and composited into the world image (snapHUDs). Drawing
-- them here as well would show each block twice, once in each place.
if self.dramaticShapeShot and snapped() then return end
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
local battle = self
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
innerHUDs(battle, slide)
end)
end
BattleState.dramaticShapeBattleHook = true
end
-- Whether each HUD block is on screen this frame.
--
-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam
-- that reports "the enemy HUD is up". A panel under a HUD that is not there
-- would be a frosted slab floating in the arena, so it is worth mirroring;
-- the worst a future engine change can do is show an empty one for a frame,
-- never break a battle.
function OverworldBattle.hudLive(battle, slide)
local enemy = battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut
and not battle:growInScale(battle.enemy) and slide == 0
and not battle.enemy.fainted
local player = battle.player and not (battle.safari or battle.demo)
and not battle.showPlayerBack and slide == 0
return enemy and true or false, player and true or false
end
-- ------- the snapped composite
--
-- The engine's own HUD layer, rendered into a texture.
--
-- One thing is falsified for the render, and it is falsified because this layer
-- never reaches the battle's zone pass -- it is composited into the world image,
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
-- answered false, it tints its own greens and reds instead, exactly as it does
-- on the flat path. The glyphs are pure black either way, which is what the
-- flip in BattleHud.layerTexture is measured against.
--
-- Shadowed on the instance for this call only, the way drawZonePass shadows
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
-- class method be found again.
function OverworldBattle.hudTexture(battle, slide, dark)
if not (innerHUDs and battle) then return nil end
local had = rawget(battle, "colorMode")
battle.colorMode = function() return false end
local ok, layer = pcall(BattleHud.layerTexture,
BattleScene.GB_W, BattleScene.GB_H, dark,
function() innerHUDs(battle, slide) end)
battle.colorMode = had
return ok and layer or nil
end
-- Draw both HUD blocks into the world image at the window's edges, each on its
-- own frosted panel. Returns true when the frame's HUDs are up there and the
-- in-frame draw must be skipped; false leaves the battle screen's own HUD
-- exactly as it was before any of this existed.
--
-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
-- carries more than the HUD: the pokeball rows of the intro and of an enemy
-- faint, and the safari ball count, all draw in these rows and belong at the
-- same edge as the block they share it with. The panels are the ones that
-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
function OverworldBattle.snapHUDs(battle, shot)
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
return false
end
local slide = (battle.introSlide or 0) * 4
local rects, bandX = OverworldBattle.snapRects(shot)
local enemy, player = OverworldBattle.hudLive(battle, slide)
local live = {}
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame
local dark = BattleHud.verdict(live, shot, true)
local layer = OverworldBattle.hudTexture(battle, slide, dark)
if not layer then return false end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(shot.canvas)
g.setBlendMode("alpha")
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
g.setColor(1, 1, 1, 1)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
BattleScene.GB_W, BattleScene.GB_H)
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return true
end
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
--
-- The fallback path only: with the HUDs snapped out to the window's edges their
-- panels went with them, and there is nothing left inside the GB frame to lay
-- glass under.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot or snapped() then return end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
local rect = OverworldBattle.HUD_RECT
local live = {}
if enemy then live.enemy = rect.enemy end
if player then live.player = rect.player end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
end
return OverworldBattle
+88 -2
View File
@@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160
-- surface shadows itself in a moire of acne; too much and a shadow detaches
-- from the foot of what casts it. The frustum is ~400 world pixels deep and
-- the packed depth resolves under 0.01 of one, so there is room.
ShadowMap.BIAS = 1.0
--
-- It cannot be ONE number, because what the comparison has to forgive is
-- not fixed: the map stores one depth for a whole texel, so a lit surface
-- reads its own depth wrong by however far it RAMPS across that texel --
-- the texel's world size times the surface's slope in the light's frame.
-- The texel swings from a third of a world pixel at the closest zoom to
-- well over one at a maximised window on the widest, so a constant bias is
-- generous at one end of the ladder and short at the other. Short shows up
-- as diagonal bands of acne across big lit surfaces -- diagonal because
-- the moire runs along neither the world grid nor the screen's, but along
-- the depth ramp in the sun's own frame, and the sun sits southeast.
--
-- So: a floor for what does not scale (the packed depth's quantisation,
-- and the two passes reaching the same world point by different matrices),
-- plus a term in texels for what does.
ShadowMap.BIAS = 0.5
-- World pixels of slack per world pixel of texel, for the steepest LIT
-- surface here: a roof pitched 45 degrees and turned away from the sun,
-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of
-- the light frame's two axes (a vertical wall, by comparison, manages 1.7,
-- flat ground 0.7, and anything steeper than that roof has its back to the
-- sun and never reads the map at all). The 2x2 filter's taps sit half a
-- texel out on both axes at once, so the worst a tap can disagree by is
-- half the ramp along each -- which is where the halving that turns 6.2
-- into 3.1 comes from, and why it is the SUM of the two components rather
-- than their magnitude.
--
-- Measured against the artefact rather than trusted: the probe
-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit
-- surfaces, and the banding stops at slack ~2.4 world px on the widest
-- rung -- where this lands 3.1 * 0.83 + 0.5.
ShadowMap.SLOPE = 3.1
-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel.
-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth
-- the map actually stores.
ShadowMap.slack = ShadowMap.BIAS
local SHADER = [[
varying float vDepth;
@@ -316,9 +353,58 @@ local function fit(cx, cy, vw, vh)
-- what the frustum ended up covering, for probes: the lateral extent in
-- world pixels divided by RES is how fine a shadow edge can land
ShadowMap.extent = { r - l, t - b, far - near }
-- the slack the comparison needs, against the coarser of the two texel
-- axes (the box is asymmetric, and one number has to cover both)
ShadowMap.slack = ShadowMap.BIAS
+ ShadowMap.SLOPE * math.max(w, h) / res
-- the stored depth spans the frustum, so a world-pixel bias is that
-- fraction of it
ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near)
ShadowMap.bias = ShadowMap.slack / math.max(1, far - near)
end
-- How much of the compare's forgiveness a snugged caster takes back, 0..1.
-- Short of 1 on purpose: at exactly 1 the card's own fragments compare
-- against their own stored depth on a float-equality knife edge and can
-- speckle. The tenth left over is dozens of times the packed depth's
-- quantization -- ample for that -- and leaves the contact gap around a
-- quarter of a world pixel at any sun, which no zoom resolves.
ShadowMap.SNUG = 0.9
-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is
-- drawn into the map.
--
-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term)
-- so lit surfaces do not acne against their own texels -- but that same
-- forgiveness is what lets the ground right next to a standing figure read
-- as lit: a receiver within `slack` of its blocker along the ray passes the
-- test, so the first stretch of every shadow is forgiven away and on screen
-- it starts that far from the feet, further the lower the sun. The classic
-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at
-- a day/night golden hour or under the moon.
--
-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow
-- falls -- every point stays on the same light ray -- but moving it toward
-- the sun stores it SHALLOWER, so a ground point right at the foot is
-- already `slack` deeper than the stored blocker and fails the lit test:
-- the root lands back under the feet. Nothing else is touched -- no
-- terrain moved, so the acne margin the slack exists for is intact where
-- it matters. For sprite cards and other thin stand-ins only.
--
-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same
-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`).
-- Stored and lookup then agree exactly, as they did before snugging, and
-- the compare keeps its full acne margin. A caster stored snugged but read
-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal
-- moire bands crawling across the card.
--
-- Valid between begin() and the next begin(): `slack` and the sun hold
-- still between redraws of the map, so a lit frame that reuses last
-- frame's map computes the same displacement it was stored with.
function ShadowMap.snug(model)
local f = sunDir()
local s = -ShadowMap.slack * ShadowMap.SNUG
return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s),
model or IDENTITY)
end
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
+466
View File
@@ -0,0 +1,466 @@
-- The sky, generated rather than shipped.
--
-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void
-- behind it is sky rather than a black plate: at 75 degrees the horizon is
-- genuinely in frame and the bands run down to meet it, and at the steeper rungs
-- the void that shows is the ground running out past the map edge, which gets
-- the same sky above the same haze. A battle's placed camera keeps the flat fill
-- it has always had -- its horizon is above the frame and its look is not this
-- rung's to change.
--
-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat
-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band
-- dithered into the bottom of each one. Alternating two colours on a pixel grid
-- is how a machine with four colours to a palette got a fifth, sixth and seventh
-- out of them, and it is what keeps four bands reading as a gradient rather than
-- as four stripes. No clouds, nothing moving.
--
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
-- back up: one full-region rectangle through a shader that answers every pixel
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
-- displayed at, so there is nothing for a filter to soften and nothing to go
-- stale when the window or the zoom changes. The shader does bind one texture,
-- but it is a palette rather than an image -- the bands, one texel each, sampled
-- nearest (see rampFor, and why it is not a uniform array).
--
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band
-- edges land on the same grid the world's own texels do, and a ZOOM keypress is
-- reflected in the frame that follows it rather than whenever something else
-- happened to rebuild.
--
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
-- is shade order: a display mode transforms a four-colour palette by replacing it
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
-- written light to dark. So the sky reads the list backwards -- deepest shade
-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
-- nothing.
--
-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
-- (four phase palettes, blended along the clock and re-quantised to the
-- lattice), and this file paints whatever the clock says: blue at noon, gold
-- and violet through the twilights -- warmed further around the low sun by a
-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
-- hang here too: cell-art discs on the same grid as the dither, scissored to
-- the sky's own region so a setting body slips below the horizon point and is
-- gone, never wandering under the map.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Sky = {}
-- The most bands a phase palette may paint with. Eight leaves headroom over
-- DayNight's six-band ones without paying for more; the ramp the shader reads
-- them from is built at the width actually used, so the cap costs nothing.
Sky.MAX_BANDS = 8
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
-- leaves the top of each band flat -- a band dithered all the way through reads
-- as one averaged colour instead of as a step with a soft bottom edge.
Sky.DITHER = true
Sky.DITHER_START = 0.6
-- How much of the frame the bands cover when the horizon is NOT in it, as a
-- fraction of the canvas height.
--
-- At the steeper rungs the camera looks down far enough that the ground plane's
-- vanishing line is above the top edge -- there is no horizon to hang the pale
-- end on, but there is still void up there where the map runs out, and it should
-- read as sky. So the bands take the same slice of the frame the top rung's own
-- horizon gives them, which keeps the sky looking like one sky across the whole
-- ladder instead of changing character rung by rung.
Sky.SPAN = 0.23
-- ------- the bands
--
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
--
-- Memoised, because this runs once a frame and the answer only moves when the
-- mode does.
local cache = { bands = nil, key = {}, ramp = nil }
function Sky.bands()
local pal = DayNight.palette()
local shades = PaletteFX.effectiveColors(pal) or pal
local n = math.min(#shades, #pal, Sky.MAX_BANDS)
local key, k = cache.key, 0
local same = cache.bands ~= nil and #cache.bands == n
for i = 1, n do
local c = shades[i]
for ch = 1, 3 do
k = k + 1
if key[k] ~= c[ch] then same = false end
key[k] = c[ch]
end
end
if same then return cache.bands end
-- the ramp is these bands as a texture (see rampFor); a new list is a new
-- ramp, and the old one is nothing's to keep
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
local bands = {}
for i = 1, n do
-- backwards: the palette's darkest rung is the top band
local c = shades[n - i + 1]
bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
cache.bands = bands
return bands
end
-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
-- bottom edge and the right flat fill for any outdoor void that wants to
-- match the clock without painting bands (the battle arena's backdrop).
function Sky.haze()
local bands = Sky.bands()
return bands and bands[#bands] or nil
end
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
-- making it the bottom band's own colour means the haze below the sky and the
-- bottom of the sky are one colour -- the join has no seam, and a frame that
-- cannot paint the bands is a hazy sky rather than a wrong one.
--
-- Mutates the descriptor, which is a fresh table per frame from its caller.
function Sky.dress(sky)
local bands = Sky.bands()
local haze = bands and bands[#bands]
if not (sky and haze) then return sky end
sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3]
sky.bands = bands
return sky
end
-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon
-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil
-- when there is no room for any of it.
function Sky.region(h, horizonY)
if not (h and h > 0) then return nil end
local edge = horizonY
if not (edge and edge > 0) then edge = h * Sky.SPAN end
edge = math.min(edge, h)
if edge < 1 then return nil end
return edge
end
-- ------- the pass
--
-- One rectangle, one shader. Every pixel answers for itself from its canvas
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
-- -- there is no image being scaled and so nothing to be soft. The one texture
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
-- sampled nearest, one lookup per pixel (see rampFor).
--
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
-- by is the top of its own cell row, so a whole cell row is one colour and every
-- edge in the sky lands on the diorama's pixel grid.
local SHADER_SRC = [[
uniform Image ramp; // the bands, one texel each, top of the sky first
uniform float count; // how many texels wide that ramp is
uniform float edge; // the sky's bottom, in canvas pixels
uniform float cell; // the diorama's pixel size, in canvas pixels
uniform float start; // where the checker begins inside a band
uniform float alpha;
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
uniform vec2 glowPos; // the sun disc, in canvas pixels
uniform float glowInvR; // 1 / the glow's reach
uniform vec3 glowColor;
// Band `i`, read from its own texel centre. The index is clamped rather than
// trusted: `pos` below can land exactly on `count` when the arithmetic is
// carried at mediump -- which is the fragment default on GLSL ES -- and a
// sample past the last band must be the last band, not whatever is off the
// end of the image.
vec3 bandAt(float i) {
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float row = floor(sc.y / cell) * cell; // top of this cell row
float pos = min(row / max(edge, 1.0), 1.0) * count;
float base = min(floor(pos), count - 1.0);
vec3 c = bandAt(base);
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (base < count - 1.0 && (pos - base) > start) {
if (parity < 0.5) { c = bandAt(base + 1.0); }
}
// The sunset's warmth, radiating from the disc: posterised to a few rungs
// and checker-dithered between them -- the same 8-bit move as the bands,
// so the glow reads as painted light rather than as a smooth airbrush --
// and measured cell-to-cell, so its rings ride the diorama's own grid.
if (glowAmt > 0.0) {
vec2 cc = (floor(sc / cell) + 0.5) * cell;
float d = length(cc - glowPos) * glowInvR;
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
float lvl = floor(g * 4.0);
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
}
return vec4(c, alpha);
}
]]
-- ------- the ramp
--
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
-- read through a loop counter. On desktop GL that is as portable as it looks.
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
-- strip running from partway down the gradient to the horizon point, with the
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
-- palest band again, but delivered by love.graphics.clear instead of by the
-- array) landing in exactly the right colour. Same colour, two routes, one of
-- them black: the fault was the array, not the palette.
--
-- Which of the ES failure modes it was hardly matters -- a driver that
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
-- because they all have the same shape: slots past the first few read as zero,
-- and zero is black.
--
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
-- there is no array to index and no budget to overrun, and a texel that does
-- not exist cannot read as black because the image is built at exactly the
-- width the shader divides by. Nearest and clamped, so a sample lands on one
-- band's own colour and an out-of-range one lands on the end band rather than
-- on nothing.
--
-- Rebuilt only when the bands move, which is when the clock or the display
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
local function rampFor(bands)
if cache.ramp and cache.rampFor == bands then return cache.ramp end
if not (love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then return nil end
local n = #bands
if n < 1 then return nil end
local ok, data = pcall(love.image.newImageData, n, 1)
if not (ok and data) then return nil end
for i = 1, n do
local c = bands[i]
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
end
local built, img = pcall(love.graphics.newImage, data)
if not (built and img) then return nil end
-- nearest: a band is a flat colour, not something to interpolate between.
-- clamp: the shader clamps its index too, so this is the second of two
-- guards against ever sampling off the end -- and it returns the edge band.
pcall(img.setFilter, img, "nearest", "nearest")
pcall(img.setWrap, img, "clamp", "clamp")
cache.ramp, cache.rampFor = img, bands
return img
end
Sky._rampFor = rampFor -- named for the suite
local shader = nil -- nil = untried, false = unavailable
local function getShader()
if shader == nil then
shader = false
if love.graphics and love.graphics.newShader then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
if ok and sh then
shader = sh
elseif V and V.mod and V.mod.log then
-- once, and only where it can be read: the fallback below is a sky
-- without its dither, which is easy to look at and impossible to
-- diagnose without this line
V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, "
.. "with no dither between them", tostring(sh))
end
end
end
return shader or nil
end
Sky._getShader = getShader -- named for the suite
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
-- quantised edges. For a driver that could not compile the shader -- which is
-- also every headless run.
local function paintFlat(w, h, bands, edge, alpha, cell)
local g = love.graphics
local n = #bands
local prev = 0
for i = 1, n do
local cut = (i == n) and math.min(h, math.ceil(edge))
or math.floor(i / n * edge / cell + 0.5) * cell
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
if cut > prev then
local c = bands[i]
g.setColor(c[1], c[2], c[3], alpha)
g.rectangle("fill", 0, prev, w, cut - prev)
end
prev = cut
end
end
-- ------- the discs
--
-- The sun and moon, as cell art: a circle of whole diorama cells with a
-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
-- through the same display-mode transform as every palette here, and
-- SCISSORED to the sky's region: the horizon point is where a setting body
-- disappears, so it can never hang under the map at a high pitch.
--
-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
-- an angle, so zooming the ground in and out must not swell and shrink the
-- sun with it. The radius is a fraction of the frame height, converted to
-- whole cells so the disc still sits on the diorama's grid -- chunky cells
-- up close, fine ones at survey zoom, the same size body either way.
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
-- crater centres as fractions of the radius, so they ride any disc size
local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
{ -0.15, 0.7 }, { 0.05, 0.05 } }
local function paintDisc(body, edge, cell, w, h)
local g = love.graphics
if not (body and body.y and g.setScissor) then return end
local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
local shades = PaletteFX.effectiveColors(src) or src
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
local r = math.max(Sky.DISC_MIN,
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
-- the low sun looms: the classic sunset exaggeration, and it reads
if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
-- snap the centre to the cell grid, like everything else in this sky
local bx = math.floor(body.x / cell) * cell + cell / 2
local by = math.floor(body.y / cell) * cell + cell / 2
if by - r * cell > edge then return end -- wholly below the horizon point
local core = shades[1]
local main = shades[twilight and 3 or 2]
local sx, sy, sw, sh = g.getScissor()
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
local craterR = math.max(1, math.floor(r / 5))
for dy = -r, r do
for dx = -r, r do
local d = math.sqrt(dx * dx + dy * dy)
if d <= r + 0.1 then
local c = d <= r * 0.5 and core or main
-- dithered rim: the outer ring keeps only one parity of its cells
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
if body.moon then
for _, cr in ipairs(MOON_CRATERS) do
local cdx = dx - math.floor(cr[1] * r + 0.5)
local cdy = dy - math.floor(cr[2] * r + 0.5)
if cdx * cdx + cdy * cdy <= craterR * craterR then
c = shades[3]
end
end
end
if keep then
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
g.rectangle("fill", bx + dx * cell - cell / 2,
by + dy * cell - cell / 2, cell, cell)
end
end
end
end
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
g.setColor(1, 1, 1, 1)
end
-- Paint the sky into the bound canvas, filling it from the top edge down to
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
--
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
--
-- `body` is the sun or moon to hang, already projected to canvas pixels by
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
-- along; nil hangs nothing and warms nothing.
--
-- Returns false when there is nothing to paint, in which case the caller's flat
-- fill is the whole sky. That fill is the palest band, so a frame that declines
-- this looks like a hazy day rather than like a bug.
function Sky.paint(w, h, sky, horizonY, cell, body)
local bands = sky and sky.bands
if not (bands and bands[1]) then return false end
if not (w and h and w > 0 and h > 0) then return false end
local g = love.graphics
if not (g and g.rectangle) then return false end
local edge = Sky.region(h, horizonY)
if not edge then return false end
local alpha = sky[4] or 1
cell = math.max(1, math.floor((cell or 1) + 0.5))
-- State to put aside. The scene's shader is one, and the blend mode another --
-- a pass that left "replace" behind would make the fade-in strength meaningless
-- -- but the DEPTH MODE is the one that would break the frame: a rectangle
-- drawn under the pass's own ("lequal", true) stamps itself across the depth
-- buffer at the near plane and hides the entire world behind the sky.
local prevShader = g.getShader and g.getShader() or nil
local cmp, write
if g.getDepthMode then cmp, write = g.getDepthMode() end
if g.setDepthMode then g.setDepthMode("always", false) end
local blend, blendAlpha
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
if g.setBlendMode then g.setBlendMode("alpha") end
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
local sh = getShader()
local ramp = sh and rampFor(bands)
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
if sh then
local sent = pcall(function()
-- the bands arrive as a texture, one texel each, and `count` is that
-- texture's width -- see rampFor for why they are not a uniform array
sh:send("ramp", ramp)
sh:send("count", #bands)
sh:send("edge", edge)
sh:send("cell", cell)
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
sh:send("alpha", alpha)
sh:send("glowAmt", glowAmt)
if glowAmt > 0 then
local gc = body.glowColor or { 248, 224, 168 }
sh:send("glowPos", { body.x, body.y })
sh:send("glowInvR", 1 / math.max(1, w * 0.55))
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
end
end)
if sent then
g.setShader(sh)
g.setColor(1, 1, 1, 1)
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
g.setShader()
else
sh = nil
end
end
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
-- the disc goes over the glow, under nothing: plain rectangles, so it is
-- there whether or not the shader built
paintDisc(body, math.min(h, edge), cell, w, h)
g.setColor(1, 1, 1, 1)
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end
if prevShader and g.setShader then g.setShader(prevShader) end
return true
end
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
-- context builds a new one instead of drawing with a handle from the old. The
-- ramp is a GPU object on the same context and goes with it.
function Sky.invalidate()
shader = nil
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
end
return Sky
+239 -21
View File
@@ -55,6 +55,14 @@ local Structures = {}
-- must match ChunkMesher's ring (3 border blocks, in tiles)
local RING = 12
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery. (Declared up here rather than
-- beside buildCylinders because forMap's grid resolve reads it too.)
local ROUND_RING = 4
-- object-mode gates
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
@@ -154,12 +162,34 @@ function Structures.forMap(map)
local TileRenderer = require("src.render.TileRenderer")
local borderId = TileRenderer.borderBlockFor(map)
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
-- TREES fill stops at ROUND_RING instead of running the full RING.
-- Only that far out does a tree cell get carved into a hull; past it
-- the cells fall through to the mesher's plain box, and a slab of
-- flat-topped boxes beside the modelled wall reads as a painted-on
-- plateau -- the wall looking like it was cut off with scissors. So
-- the far ring is simply not built: beyond ROUND_RING tileLookup
-- answers nil, which is the same "nothing out there" BLACK already
-- produces and every pass below already copes with. The cut lands on
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
-- of 4 tiles, so no group is left half-resolved at the edge.
--
-- WATER and the other tilesets' own borders keep the full ring: a flat
-- sheet of water is what water looks like from above anyway, and an
-- interior's border is black already.
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
and (TileRenderer.voidFill or "trees") == "trees"
local tw2, th2 = tw, th
local function tileLookup(tx, ty)
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
return map:tileAt(tx, ty)
end
if not borderBlk then return nil end
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
or tx >= tw2 + ROUND_RING
or ty >= th2 + ROUND_RING) then
return nil
end
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
end
local shapeAt, tileAt = {}, {}
@@ -192,8 +222,9 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {} }
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -268,6 +299,14 @@ function Structures.forMap(map)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
@@ -880,13 +919,6 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
return quads, bg
end
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery.
local ROUND_RING = 4
-- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set):
-- the same four-tile tree repeats for hundreds of cells on a map and
-- across every route of its tileset, so the carve runs once per distinct
@@ -1214,6 +1246,9 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
end
function Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
-- Read once: it is a property of the tileset, not of the column.
local backfill = TileShape.bookcaseBackfill(map.tileset.id)
for tx = x0, x1 do
local ty = y1
while ty >= y0 do
@@ -1241,10 +1276,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1)
capTile = S.tileAt[ck]
end
end
-- The box is one cell deep, so it covers only the run's southmost
-- rows; everything north of that is vacated. By default a vacated
-- row is skipped and painted with synthesized ground -- right for a
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
-- the cell above the run instead, shape and art, so a wall cut into
-- a terrace has more terrace behind it rather than a trench.
local covered = math.min(2, front - top + 1)
local srcK = keyOf(tx, top - 1)
local src = backfill == "above" and S.shapeAt[srcK] or nil
for cy = top, front do
local tk = keyOf(tx, cy)
S.skip[tk] = true
S.ground[tk] = false
if src and cy <= front - covered then
S.shapeAt[tk] = src
S.tileAt[tk] = S.tileAt[srcK]
else
S.skip[tk] = true
S.ground[tk] = false
end
end
bookcaseRank(S, map, tx, top, front, capTile)
front = top - 1
@@ -1700,18 +1749,44 @@ function Structures.extractObjects(S, map, region, data, perRow, force)
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
strict = fs ~= nil and fs.class == "cutout"
end
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
-- prop whose body reaches its own edge votes itself out. The Center's
-- potted plants are the case: the pot's olive base is drawn flush on the
-- bottom row of the block, so "dark" came back as background and every
-- dark pixel in the whole plant drained with it -- the pots rendered as
-- hollow black frames while the 2D art has solid olive bodies.
--
-- Where the vote misreads the art, the profile can name the background
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
-- per tileset, because the answer is per drawing: the healing consoles'
-- screens really do stand on a dark wall band and really do need dark
-- voted out, and the PC really does need light kept.
local bg = {}
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
do
local named = TileShape.propBg(map.tileset.id)
if named then
for _, c in ipairs(region.tiles) do
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
if rule then
for shadeName in pairs(rule) do bg[shadeName] = true end
break
end
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
if not next(bg) then
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
end
for i, st in pairs(state) do
if strict then
if st == "dark" or st == "light" then
@@ -1943,8 +2018,13 @@ function Structures.buildObject(S, map, region, cluster,
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
math.floor(cluster.maxY / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
-- `bookcase` supports as well as `upright`. A prop drawn above an
-- authored box stands ON it whatever art the box renders with, and a
-- stacked box is still a box: the Plateau's gate pilasters carry a
-- statue on 48 of their tops, and collapsing the pilaster to a stacked
-- run made every one of them fail this test and drop to ground level.
if blocked and bs and bs.authored and (bs.h or 0) > 0
and (bs.art == "upright" or bs.art == "bookcase") then
baseY, support = bs.h, bs
end
end
@@ -2078,7 +2158,8 @@ function Structures.buildObject(S, map, region, cluster,
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
if support and support.class == "wall" then
if support and (support.class == "wall" or support.class == "cliff"
or support.art == "bookcase") then
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
-- plinth) is a statue on a pillar with ONE cell of footprint: the
-- block below already carries the whole base, so the drawn cell
@@ -2086,6 +2167,13 @@ function Structures.buildObject(S, map, region, cluster,
-- the base backwards. Furniture supports (a monitor on its desk)
-- keep the box-extension below -- their drawn cell is the
-- furniture's own upper rows, and floor there would amputate it.
--
-- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
-- full-height blocks like `wall` and belong here, while `desk` is
-- 24px and still furniture. The Plateau's statues on stacked
-- pilasters found this -- taking the furniture branch turned each
-- statue's own two rows into a 32px box wearing the pilaster's art,
-- so every one of them stood inside a slab of its own plinth.
S.skip[k] = true
S.ground[k] = best
elseif support then
@@ -2116,6 +2204,136 @@ function Structures.buildObject(S, map, region, cluster,
return true
end
-- ---- figures: a person drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position.
--
-- The mask IS the classification: no flood, no shade segmentation, no
-- validation gate. Every automatic route in this file asks the art where
-- the object ends, and a figure painted into its own furniture has no
-- answer to give -- so the profile answers instead, and this only has to
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
--
-- A figure is a SPRITE, not a prop. It gets exactly the treatment
-- SpriteBillboards gives a character: one flat plane of the drawing's own
-- pixels, no thickness, standing at its feet and leaned back by the
-- camera's pitch at draw time so it always reads face-on -- because that
-- is what the artwork is. A seated man drawn face-on is a 2D icon like
-- every other Gen 1 figure; extruding him into a slab reconstructs a body
-- nobody drew (the ten-voxel version read as a wedge of furniture, and
-- even one voxel showed an edge the sprites never show).
--
-- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
-- from. One quad per pixel rather than one alpha-keyed texture: the
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
-- same silhouette straight out of the live atlas, so every palette bake
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return fig.mask[ly * bw + lx] or false
end
-- his feet and his west edge: the card's own origin
local lowY, minX = 0, bw - 1
for ly = 0, bh - 1 do
for lx = 0, bw - 1 do
if at(lx, ly) then
if ly > lowY then lowY = ly end
if lx < minX then minX = lx end
end
end
end
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one.
local baseY = 0
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY = bs.h
end
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = fig.tiles[math.floor(ly / 8) * fig.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = lx - minX, lowY - ly
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
u = u, v = v, shade = 1 }
end
end
end
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet are
-- drawn in, which is the same convention a character card uses (its feet
-- plane sits at its cell's middle) -- so he sorts against the couch and
-- against a player walking past exactly the way an NPC standing there
-- would.
S.figures[#S.figures + 1] = {
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY,
}
-- What each covered tile wears now that he is off it. Only the ART
-- changes: the couch tiles keep their `counter` box (they ARE the
-- couch) and the floor tiles he overhung stay flat floor -- the
-- profile just names the version of each drawing without him in it,
-- so nothing has to be synthesized or repainted from a neighbour vote.
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
end
end
-- Every authored figure, wherever the map draws it.
--
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
-- places this couch once in each of the eleven Pokemon Centers (and the
-- Celadon Hotel), so the pattern finds all of them without the profile
-- naming a single map or cell. The repaint above replaces the pattern's
-- own tiles, so a match can never fire twice on the same drawing.
function Structures.buildFigures(S, map, x0, x1, y0, y1)
local figures = TileShape.figures(map.tileset.id)
if not figures then return end
local perRow = map.tileset.tilesPerRow or 16
for _, fig in ipairs(figures) do
for ty = y0, y1 - fig.h + 1 do
for tx = x0, x1 - fig.w + 1 do
Budget.tick()
local hit = true
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
hit = false
break
end
end
if hit then buildFigure(S, map, fig, tx, ty, perRow) end
end
end
end
end
-- ---- tall grass ----
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
+189 -17
View File
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
sign = 12,
wall = 16,
tree = 16,
-- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
-- badge-check gates down Route 23 are drawn 32px, the same height as a
-- statue on its plinth, and read as a step in the terrain rather than a
-- room's wall. Same fold as `wall`, twice the height -- and its own
-- class because `wall` is 16px for every interior in the game.
cliff = 32,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
@@ -110,6 +116,7 @@ local ART = {
ledge = "top",
roof = "top",
wall = "upright",
cliff = "upright",
tree = "upright",
fence = "upright",
sign = "upright",
@@ -168,6 +175,8 @@ local ART = {
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
local figCache = {} -- tileset id -> parsed figure masks, or false
local bgCache = {} -- tileset id -> prop background shades, or false
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
-- through the mod's own file loader rather than package.path: a mod's
@@ -224,29 +233,42 @@ end
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
-- the map and coordinates are in hand. First match wins; no match keeps
-- the tile's ordinary pin.
-- `when_below` is the mirror, and it exists because ABOVE is not always the
-- side that tells the two uses apart. The Plateau's $0D is the case: it is
-- the gate wall's top band AND the base course under a column of rock face,
-- and scanned over both maps the tile above is $03 for 64 of the first and
-- 140 of the second -- no rule on `above` can split them. What is BELOW
-- does, exactly: the wall's own face $0F sits under the top band and under
-- nothing else (336 vs 352, clean).
local function authoredConditions(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local spec = entry and entry.when_above
if type(spec) ~= "table" then return nil end
if type(entry) ~= "table" then return nil end
local out, any = {}, false
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule.above) == "table" then
local set = {}
for _, t in ipairs(rule.above) do set[t] = true end
list[#list + 1] = { above = set, class = rule.class }
local function collect(spec, side)
if type(spec) ~= "table" then return end
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = out[tile] or {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule[side]) == "table" then
local set = {}
for _, t in ipairs(rule[side]) do set[t] = true end
list[#list + 1] = { side = side, set = set, class = rule.class }
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
end
collect(entry.when_above, "above")
collect(entry.when_below, "below")
return any and out or nil
end
@@ -348,9 +370,12 @@ function TileShape.at(map, shapes, tile, tx, ty)
-- tile and the cell rules below (see authoredConditions)
local rules = shapes.cond and shapes.cond[tile]
if rules then
local above = map:tileAt(tx, ty - 1)
for _, rule in ipairs(rules) do
if above and rule.above[above] then
-- NOTE map:tileAt border-EXTENDS: one row off an edge answers the
-- map's borderBlock, never nil. A rule listing whatever that block
-- draws will fire along that whole edge (it did, on the Marts).
local n = map:tileAt(tx, rule.side == "above" and ty - 1 or ty + 1)
if n and rule.set[n] then
-- shapes.condShape, NOT shapes.classes: the canonical class
-- shapes are SHARED, and `wall` in particular is the very object
-- rule 4 hands every unauthored solid tile. Marking that one
@@ -369,11 +394,158 @@ function TileShape.at(map, shapes, tile, tx, ty)
return s
end
-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture,
-- cut out by an explicit pixel mask and stood up on top of it.
--
-- Every other route in this file resolves a whole 8x8 TILE, which is
-- exactly why none of them can reach a figure that shares its tiles with
-- the thing it sits on -- and the detector's segmentation cannot either
-- when the drawing has no background margin to flood from and wears the
-- same shades as its furniture. So the profile authors the silhouette
-- pixel by pixel (see data/voxel_heights.lua):
--
-- figures = { { w = <tiles across>,
-- tiles = { ...w*h tile ids, row-major... },
-- under = { ...w*h ids: what each tile wears once the
-- figure is lifted off it... },
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
-- figure... } } }
--
-- No class: a figure is always a flat sprite card, drawn the way
-- SpriteBillboards draws a character (see Structures.buildFigures).
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
function TileShape.figures(tilesetId)
local hit = figCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.figures
local out = {}
if type(list) == "table" then
for _, f in ipairs(list) do
local ok = type(f) == "table" and type(f.w) == "number"
and type(f.tiles) == "table" and type(f.under) == "table"
and type(f.pixels) == "table"
local w = ok and math.floor(f.w) or 0
local h = (w >= 1) and (#f.tiles / w) or 0
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
and #f.under == #f.tiles and #f.pixels == h * 8
if ok then
for i = 1, h * 8 do
local row = f.pixels[i]
if type(row) ~= "string" or #row ~= w * 8 then
ok = false
break
end
end
end
if ok then
local mask, n = {}, 0
for ly = 0, h * 8 - 1 do
local row = f.pixels[ly + 1]
for lx = 0, w * 8 - 1 do
if row:sub(lx + 1, lx + 1) ~= "." then
mask[ly * (w * 8) + lx] = true
n = n + 1
end
end
end
if n > 0 then
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
end
end
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
--
-- Structures normally votes on this by reading the shades that touch the
-- drawing's own bounding box, which is right whenever the drawing has a
-- margin of floor around it and wrong when it does not: a prop whose body
-- reaches its own edge votes itself out. Naming the shades is the override,
-- and it is keyed by TILE because the answer is per drawing rather than per
-- tileset -- two props in one atlas can want opposite calls on the same
-- shade (see the POKECENTER entry).
--
-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } }
--
-- Only the four GB shade names exist; anything else is dropped, so a typo
-- degrades to the ordinary vote rather than emptying the background.
local SHADES = { black = true, dark = true, light = true, white = true }
function TileShape.propBg(tilesetId)
local hit = bgCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.prop_bg
local out, any = {}, false
if type(list) == "table" then
for _, rule in ipairs(list) do
if type(rule) == "table" and type(rule.tiles) == "table"
and type(rule.shades) == "table" then
local set, n = {}, 0
for _, name in ipairs(rule.shades) do
if SHADES[name] then
set[name] = true
n = n + 1
end
end
if n > 0 then
for _, t in ipairs(rule.tiles) do
if type(t) == "number" then
out[t] = set
any = true
end
end
end
end
end
end
bgCache[tilesetId] = any and out or false
return bgCache[tilesetId] or nil
end
-- What a bookcase rank does with the rows it VACATES -- the ones behind the
-- one-cell-deep box it collapses onto (a tileset entry's
-- bookcase_backfill). Returns the mode name, or nil for the default.
--
-- "above" hand them the cell immediately above the run: its shape and
-- its art. A wall set INTO a terrace wants this -- the ground
-- behind it is more terrace, not a trench.
-- nil skip them and paint the map's commonest ground underneath,
-- which is right for a free-standing shelf against a wall.
--
-- Per tileset because it is a statement about what the drawing depicts, and
-- the answer differs: the Mart's racks and Red's shelves stand in a room,
-- the Plateau's gate walls are cut into a hillside.
function TileShape.bookcaseBackfill(tilesetId)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local mode = entry and entry.bookcase_backfill
return mode == "above" and mode or nil
end
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
function TileShape.invalidate()
spec = nil
cache = {}
figCache = {}
bgCache = {}
end
return TileShape
+293 -8
View File
@@ -30,6 +30,9 @@ local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local GlassMask = V.require("GlassMask")
local Voxel3D = {}
@@ -200,6 +203,13 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
uniform float glassPhase; // the glint's phase: advances with TRAVEL
uniform float glassGlint; // and its strength: 0 while standing still
uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass)
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
@@ -207,12 +217,44 @@ local SHADER = [[
// blending keeps those texels out of the depth buffer, so a model never
// carves a transparent hole out of whatever stands behind it
if (p.a < 0.5) discard;
vec3 rgb = p.rgb * vShade * sunlight(vSun);
// the hour's tint multiplies like the sun terms do: it is LIGHT, the
// same warm or moonlit cast on every surface, not a palette swap
vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
#ifdef VOXEL_GRID
// darken what is there rather than painting a colour, so a seam across
// dark grass and one across a white roof each stay in their own palette
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
#endif
// WINDOW GLASS, marked per atlas texel by the mask (see GlassMask).
// By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES
// -- the phase is fed by the camera's own travel and the strength dies
// within a beat of standing still, because a reflection is something
// the viewpoint does: still camera, still glass. It lifts the texel
// toward sky-white and leaves the art visible through it. After dark
// the pane is LIT: the texel's own shine pattern carried into a warm
// lamp colour, replacing the shaded answer above -- so a lit window
// ignores the sun, every shadow and the hour's tint, exactly as a
// window with a lamp behind it does.
// glassOn gates the whole thing per DRAW: the mask is shaped like the
// tileset atlas, and only meshes textured FROM that atlas may consult
// it -- a character samples its own sprite sheet, whose coordinates
// land on the mask's pane rectangles by accident and would stripe the
// cast with lamplight at night.
float glass = Texel(glassMask, tc).a * glassOn;
if (glass > 0.0) {
// the sweep lives in the PANE's own space (atlas texels), not the
// screen's: a pattern anchored to the screen has the world sliding
// through it at zoom speed whenever the camera pans, which strobed --
// worst where the pan and the phase ran opposite ways. Anchored to
// the glass, panning moves nothing; only the phase does, a fraction
// of a texel per step, the same in every walking direction.
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
@@ -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
+12
View File
@@ -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
View File
@@ -1,4 +1,4 @@
-- Voxel world mode: assemble and draw one frame of the 3D scene.
-- Voxel world mode: assemble and draw one frame of the 3D scene.
--
-- World space is world pixels and shares its origin with the 2D paths, so
-- the terrain mesh needs no transform at all and a connected map just
@@ -20,6 +20,8 @@ local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -49,12 +51,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- At the top rung the camera is pitched far enough over that the horizon
-- comes into frame and a good part of the picture is void -- so the void
-- becomes the sky, and the diorama reads as standing under something
-- rather than floating on a black plate. Below that rung the camera looks
-- down steeply enough that the horizon is off-screen, and painting the
-- void only tints the gaps between meshes, so it stays transparent.
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
@@ -67,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
View File
@@ -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
+341 -12
View File
@@ -77,6 +77,15 @@ 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")
-- 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 +153,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 +214,7 @@ mod.content.render_pipelines:register("voxel", {
invalidate = function()
Voxel3D.invalidate()
OverworldBattle.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
end,
})
@@ -222,10 +251,80 @@ 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 rather than forced -- the row is gone from the
-- menu while FULL is on, but a save that already had it off gets it on.
OverworldBattle.setting: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: 3D-BTL is on, or FULL is selected -- which owns that row and
-- switches it on. 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()
local Pipelines = require("src.render.Pipelines")
return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift." },
{ 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 +335,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
@@ -268,6 +368,7 @@ local HOTKEYS = {
["6"] = "pipeline", -- tiltshift, likewise
["5"] = VoxelGrid.setting,
["7"] = WorldCurve.setting,
["8"] = OverworldBattle.setting,
}
do
@@ -283,7 +384,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 +418,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 +439,68 @@ 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 every one of those settings, so while it is selected they are
-- taken off the menu rather than left to be changed under it -- including
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
-- longer decides anything is worse than no row.
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
-- 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")
-- 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
if Voxel.isFull(Pipelines.level("voxel")) then
-- FULL keeps every mod row off the menu (the early return skips the
-- insert below), and holds DAYTIME at SYNC while the row is unreachable
DayNight.forceSync(game)
return dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
return insertGrouped(out, extra)
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -334,6 +510,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 +601,151 @@ 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()
-- ------- 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()
end)
mod.events:on("save.created", function()
DayNight.restore()
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.2.1"
-- 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
View File
@@ -1,17 +1,19 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.0.6",
"version": "1.2.1",
"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."
}
+14 -5
View File
@@ -1,14 +1,18 @@
-- 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",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
@@ -17,17 +21,22 @@ return {
"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",
"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 },
}
+233
View File
@@ -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
+130
View File
@@ -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
+109
View File
@@ -0,0 +1,109 @@
-- Driver: screenshot the day/night cycle.
--
-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
-- mid-blend moments of the running cycle, a battle staged under the night
-- sky, and a room at midnight -- which must look exactly like a room at
-- noon, because indoors the clock does not reach.
--
-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local DayNight = lib.require("DayNight")
local function setTime(value, clock)
DayNight.setting:sync(value)
if clock then DayNight.clock = clock end
DayNight.update(0)
end
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
-- straight to the top rung, whatever the persisted options say: pressing
-- the key would walk the ladder RELATIVE to wherever a previous run left it
local Pipelines = require("src.render.Pipelines")
Pipelines.setLevel("voxel", 5)
U.wait(150) -- let the camera ease over and the meshes land
-- ------- the four pins, one vantage
for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
setTime(phase)
U.wait(30) -- a couple of rig steps + palette settle
U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
end
-- ------- the running cycle, mid-blend
setTime("cycle", 560) -- day leaning into dusk
U.wait(10)
U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
setTime("cycle", 645) -- dusk falling into night
U.wait(10)
U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
-- ------- golden hour on open ground, long shadows
U.teleport(game, "ROUTE_1", 8, 12, "up")
U.wait(120)
setTime("cycle", 555) -- low western sun, shadows long and eastward
U.wait(30)
U.shot(game, DIR .. "/30_route1_low_sun.png")
-- ------- a fight staged under the night sky
setTime("night")
game.save.party = game.save.party or {}
if #game.save.party == 0 then
game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
end
-- any real trainer class: the dataset's ids vary by merge, so take the
-- first one in stable order rather than guessing a name
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
if battle then
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
U.shot(game, DIR .. "/40_night_battle.png")
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
-- ------- and a room, where midnight must change nothing
setTime("night")
if game.data.maps.REDS_HOUSE_1F then
U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
U.wait(90)
U.shot(game, DIR .. "/50_indoor_at_night.png")
end
-- ------- the forest, where only the TINT of it reaches: night falls
-- through the canopy, but there is no sky and the noon light stays put
setTime("night")
if game.data.maps.VIRIDIAN_FOREST then
U.teleport(game, "VIRIDIAN_FOREST", 17, 20, "up")
U.wait(120)
U.shot(game, DIR .. "/51_forest_night.png")
setTime("day")
U.wait(30)
U.shot(game, DIR .. "/52_forest_day.png")
end
setTime("day")
U.log("done -- " .. DIR)
end
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
-- Probe: one overworld battle, held on the menu beat, shot large.
--
-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
love.math.setRandomSeed(20260727)
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
game.save.player.name = "RED"
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
U.log("DRAMATIC_SHAPE lib:", tostring(lib))
local Battles = lib and lib.require("OverworldBattle")
U.log("OverworldBattle:", tostring(Battles))
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(120)
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
U.log("class:", classes[1])
local battle = BattleState.newTrainer(game, classes[1], 1)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local arena = Battles and Battles.arena()
U.log("arena:", arena and arena.shape or "none")
U.shot(game, DIR .. "/probe_menu.png")
for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end
U.wait(90)
U.shot(game, DIR .. "/probe_menu2.png")
U.log("done -- " .. DIR)
love.event.quit()
end
+55
View File
@@ -0,0 +1,55 @@
-- Driver: prove the banded sky still paints from the ramp texture.
--
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
-- Android delivered only its first few slots and painted the rest of the sky
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
-- three things that swap could have broken, on the machine it CAN be checked
-- on: that the shader still compiles, that the ramp is built and is one texel
-- per band, and that what lands on screen is still a gradient that pales
-- downward rather than a flat plate or a black one.
--
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local Sky = lib.require("Sky")
local DayNight = lib.require("DayNight")
-- which copy of the mod is actually live: only the ramp build has this
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
require("src.render.Pipelines").setLevel("voxel", 5)
U.wait(150)
for _, phase in ipairs({ "day", "dusk", "night" }) do
DayNight.setting:sync(phase)
DayNight.update(0)
U.wait(30)
local bands = Sky.bands()
local ramp = Sky._rampFor and Sky._rampFor(bands)
local w = ramp and ramp:getWidth() or -1
U.log(("%s: %d bands, ramp %dx%d"):format(
phase, #bands, w, ramp and ramp:getHeight() or -1))
-- one texel per band is the whole contract: the shader divides by `count`
-- and samples texel centres, so a ramp of any other width samples between
-- bands or off the end
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
-- and the ramp is the ramp: the same table gives the same image back
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
U.shot(game, ("%s/%s.png"):format(DIR, phase))
end
DayNight.setting:sync("day")
U.log("done -- " .. DIR)
end
+175
View File
@@ -0,0 +1,175 @@
-- Driver: isolate the diagonal banding on flat lit geometry.
--
-- Shoots the SAME stand point four ways in ONE launch, so palette, camera
-- and framing are identical and the only variable is the sun pass:
--
-- _on shadows as shipped
-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it
-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off
--
-- and prints, from the frustum the run actually fitted, how much depth
-- slack each face orientation NEEDS against the slack it is given. A face
-- whose need exceeds the slack shadows itself in a moire -- acne -- which
-- reads as diagonal banding, since the ramp it aliases against runs along
-- the light's frame and the sun sits southeast.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec .
--
-- knobs (env):
-- ACNE_MAP map id (default VIRIDIAN_CITY)
-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall)
-- ACNE_LEVELS comma list of voxel levels (default "3")
-- ACNE_FLAT comma list of constant-bias values to compare against
-- (default "1" -- what shipped before SLOPE existed)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY"
local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "up"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing")
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local Mat4 = V.require("Mat4")
local levels = {}
for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do
levels[#levels + 1] = tonumber(n)
end
local flats = {}
for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do
flats[#flats + 1] = tonumber(n)
end
-- ---- what the frustum this run fitted asks of the bias, per face
local FACES = {
{ "top +Y", 0, 1, 0 },
{ "south +Z", 0, 0, 1 },
{ "east +X", 1, 0, 0 },
{ "west -X", -1, 0, 0 },
{ "north -Z", 0, 0, -1 },
{ "roof 45 N", 0, 0.7071, -0.7071 },
{ "roof 45 S", 0, 0.7071, 0.7071 },
}
local function report(tag)
local e = ShadowMap.extent
if not e then print("[acne] " .. tag .. ": no frustum yet") return end
local res = ShadowMap.res
local texX, texY, depth = e[1] / res, e[2] / res, e[3]
local d = ShadowMap.sunDir()
local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 })
local R = { view[1], view[2], view[3] }
local Up = { view[5], view[6], view[7] }
local F = { -view[9], -view[10], -view[11] }
print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f"
.. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)")
:format(tag, res, e[1], e[2], depth, texX, texY,
ShadowMap.slack, ShadowMap.bias))
for _, fc in ipairs(FACES) do
local n = { fc[2], fc[3], fc[4] }
local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3]
local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3]
local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3]
-- a face the sun cannot see never samples its own depth, so it
-- cannot alias -- FACE_SHADE darkens those and the map is moot
if nf < 0 then
-- the plane's depth gradient in light space, times the half-texel
-- the 2x2 filter reaches out on each axis
local need = 0.5 * (math.abs(nr / nf) * texX
+ math.abs(nu / nf) * texY)
print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s")
:format(fc[1], math.abs(nf),
math.sqrt((nr/nf)^2 + (nu/nf)^2), need,
need > ShadowMap.slack and "<-- ACNE" or "ok"))
else
print(("[acne] %s unlit (sun behind it)"):format(fc[1]))
end
end
end
local Zoom = require("src.render.Zoom")
Zoom.reset()
local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0)
for _ = 1, math.abs(steps) do
Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
end
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
do
local vw, vh = game.renderer:worldViewSize()
print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d")
:format(mapId, sx, sy, facing, vw, vh, Zoom.offset))
end
local shipped = Voxel3D.SHADOW_ALPHA
local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE
-- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid
-- on/off), so a band can be attributed to one of them rather than
-- guessed at. The grid is the other thing in this pass that draws
-- regular lines, and at a grazing angle it moires.
local VoxelGrid = V.require("VoxelGrid")
local matrix = os.getenv("ACNE_MATRIX") == "1"
local function shoot(name)
game.capturePath = ("%s/acne_%s.png"):format(DIR, name)
wait(4)
end
for _, level in ipairs(levels) do
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the camera tween
local label = Pipelines.levelLabel("voxel", level) or level
print(("[acne] --- level %s shadowsActive=%s")
:format(label, tostring(Voxel3D.shadowsActive())))
report("as shipped")
shoot(("%s_v%s_on"):format(mapId, label))
if matrix then
for _, grid in ipairs({ true, false }) do
VoxelGrid.sync(grid)
for _, sun in ipairs({ true, false }) do
Voxel3D.SHADOW_ALPHA = sun and shipped or 0
wait(8)
shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label,
grid and "1" or "0", sun and "1" or "0"))
end
end
Voxel3D.SHADOW_ALPHA = shipped
end
-- the sun pass still runs; the main pass simply stops reading it
Voxel3D.SHADOW_ALPHA = 0
wait(6)
shoot(("%s_v%s_off"):format(mapId, label))
Voxel3D.SHADOW_ALPHA = shipped
for _, b in ipairs(flats) do
ShadowMap.BIAS, ShadowMap.SLOPE = b, 0
ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias
wait(8)
report(("flat %.1f"):format(b))
shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p")))
end
ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope
ShadowMap.invalidate()
wait(6)
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[acne] done")
end
+245
View File
@@ -0,0 +1,245 @@
-- Figures: a person drawn INTO furniture, cut out by an authored pixel
-- mask and stood up on top of it (TileShape.figures / Structures.
-- buildFigures). The Pokemon Center's seated man is the case the feature
-- exists for, so this drives the real profile entry over a hand-built
-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08,
-- as every Center places it:
--
-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN,
-- y=9 52 53 60 27 57/60 floor he overhangs east onto,
-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base
-- y=11 42 43 26 27
--
-- No love, no GPU and no pixel access: a figure is authored rather than
-- detected, which is exactly what lets it build headless.
--
-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader)
local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
local V = { path = ROOT }
local function chunkFor(rel)
local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing")
local src = f:read("*a")
f:close()
return assert(load(src, "@" .. ROOT .. "/" .. rel))
end
local modules, dataFiles = {}, {}
function V.require(name)
if modules[name] == nil then
modules[name] = chunkFor("lib/" .. name .. ".lua")(V)
end
return modules[name]
end
function V.data(name)
if dataFiles[name] == nil then
dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V)
end
return dataFiles[name]
end
local TileShape = V.require("TileShape")
local Structures = V.require("Structures")
-- ------- the authored mask parses
local figs = TileShape.figures("POKECENTER")
T.check(type(figs) == "table" and #figs == 1,
"POKECENTER carries exactly one figure")
local fig = figs[1]
T.eq(fig.w, 3, "the figure is three tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans")
T.check(fig.class == nil,
"a figure carries no class -- it is always a flat sprite card")
local W = fig.w * 8
local function on(lx, ly) return fig.mask[ly * W + lx] == true end
-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the
-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7
-- only, rows 3-7 are his hair in both
T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1),
"the arm's own top rows stay with the couch")
T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2")
for ly = 3, 7 do
T.check(on(6, ly) and on(7, ly),
"the back of his head fills both columns at row " .. ly)
end
-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND
-- the right side of his face, and masking it out by shade slit his cheek
for ly = 5, 8 do
T.check(on(15, ly),
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
end
T.eq(fig.tiles[1], 36, "it starts at the couch's west arm")
T.eq(fig.tiles[2], 37, "then the tile his head is drawn in")
T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off")
T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion")
T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)")
T.eq(fig.under[6], 26, "and (60 -> 26)")
-- the mask is one connected figure: an overhang that floats free of him
-- is the failure this feature exists to avoid
local seen, first = {}, nil
for i in pairs(fig.mask) do first = first or i end
local stack, reached = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
reached = reached + 1
local x, y = i % W, math.floor(i / W)
for dy = -1, 1 do
for dx = -1, 1 do
local nx, ny = x + dx, y + dy
local ni = ny * W + nx
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W
and fig.mask[ni] and not seen[ni] then
seen[ni] = true
stack[#stack + 1] = ni
end
end
end
end
T.eq(reached, fig.n, "the mask is a single connected figure")
-- ------- the couch block, as the Centers place it
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
local COUNTER = { class = "counter", h = 8, art = "upright",
flat = false, authored = true }
local GROUND = { class = "ground", h = 0, art = "flat",
flat = true, authored = false }
local BLOCK = { 36, 37, 57, 11,
52, 53, 60, 27,
38, 39, 54, 11,
42, 43, 26, 27 }
local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true,
[42] = true, [43] = true, [52] = true, [53] = true }
local function scene()
local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
ground = {}, runs = {} }
for i = 1, 16 do
local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4)
local tile = BLOCK[i]
S.tileAt[keyOf(tx, ty)] = tile
S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND
end
return S
end
-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn
-- sitting on it), the floor cell east of it is walkable
local map = {
tileset = { id = "POKECENTER", tilesPerRow = 16,
imageWidth = 128, imageHeight = 48 },
isWalkableCell = function(_, cx) return cx >= 1 end,
}
-- ------- he comes off the couch
local S = scene()
Structures.buildFigures(S, map, 0, 3, 8, 11)
T.eq(#S.figures, 1, "one figure card was built")
local card = S.figures[1]
T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else")
T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now")
T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion")
T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too")
T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again")
T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it")
T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched")
T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base")
-- the couch keeps its box: a figure changes ART, never class
T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
"his tiles are still the couch's half-cell box")
T.check(S.skip[keyOf(1, 8)] ~= true,
"and are not skipped -- the couch still renders there")
-- ------- the card is flat, and stands at its feet
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(card.quads) do
for c = 1, 4 do
local p = q[c]
minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1])
minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2])
minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3])
end
end
T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)")
T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab")
T.eq(minY, 0, "local space: his feet are the card's origin")
T.eq(maxY, 16, "and he is his drawn 16px tall")
T.eq(minX, 0, "his west edge is the card's origin too")
T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang")
-- ------- and where VoxelScene stands it
T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor")
T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6")
T.eq(card.wz, 76,
"and pivoting at the middle of the tile row his feet are drawn in")
-- ------- a map that does not draw him builds nothing
local other = scene()
other.tileAt[keyOf(1, 8)] = 40
Structures.buildFigures(other, map, 0, 3, 8, 11)
T.eq(#other.figures, 0, "no match, no figure")
T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted")
-- ------- and it never fires twice on the same drawing
local twice = scene()
Structures.buildFigures(twice, map, 0, 3, 8, 11)
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
-- the bottom of the plant block, so the ordinary vote (shades touching the
-- drawing's own bounding box) read "dark" as background and drained every
-- olive pixel in the plant.
local bgRules = TileShape.propBg("POKECENTER")
T.check(type(bgRules) == "table", "POKECENTER names prop background shades")
for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do
local set = bgRules and bgRules[tile]
T.check(type(set) == "table" and set.light and set.white
and not set.dark and not set.black,
"plant tile " .. tile .. ": light/white are background, dark is the pot")
end
-- and it is scoped: the healing consoles' screens and the PC keep the
-- ordinary vote, because they want the opposite call on those same shades
for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do
T.check(bgRules[tile] == nil,
"tile " .. tile .. " keeps the ordinary background vote")
end
-- a tileset with no prop_bg at all answers nil rather than an empty table,
-- so Structures can skip the lookup entirely
T.check(TileShape.propBg("CAVERN") == nil,
"a tileset that names none answers nil")
T.finish("DRAMATIC_SHAPE figures")
+122
View File
@@ -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)