mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-13 15:00:58 +02:00
Compare commits
5 Commits
add-ledges
..
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| c82598b24c | |||
| 47363b8d23 | |||
| 752653e243 | |||
| 6887f5d951 | |||
| a140980b1d |
+154
-55
@@ -1,64 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 1.4.1
|
||||
|
||||
### Added
|
||||
|
||||
- **Furniture through the building pipeline.** The band-table voxelizer
|
||||
that models whole buildings from their own drawings (lib/Buildings.lua)
|
||||
now reads interior furniture too, and the first four drawings are in:
|
||||
|
||||
- **F01, the starter-ball table in Oak's lab** -- the tabletop's 16
|
||||
drawn rows lay flat over a 16px plot (1:1, the first template that
|
||||
never cycles), the black/#555/black edge band folds into the slab's
|
||||
own rim, and the base extrudes with its corner feet. Six voxels
|
||||
tall, exactly the drawn elevation.
|
||||
- **F03, the empty north table beside it** -- the same band table on a
|
||||
grid two tiles narrower.
|
||||
- **F02, the lab's computer desk** -- the first DESK-SET template: the
|
||||
drawing segments into PARTS, each classified by the surface it
|
||||
depicts. The monitor and the computer tower stand upright on the
|
||||
desk wearing their own drawn tops as lids; the keyboards and the
|
||||
mouse lie flat in front of them; the sheet of paper on the right
|
||||
lies flat across the desk. Flat parts keep the drawing's own rule --
|
||||
drawn row IS depth row, the same 1:1 the tabletop is drawn with --
|
||||
so an object's height on the drawing is its position on the desk.
|
||||
The Hall of Fame's recording machine is this drawing tile for tile
|
||||
on the GYM atlas, and models identically for free.
|
||||
- **F04, the Center PC** -- the desk-set read again: a Mac-style unit
|
||||
with its screen and drive slot in relief, standing at the back of a
|
||||
low white-topped desk with its keyboard lying at the front edge.
|
||||
Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART
|
||||
tileset shares the atlas.
|
||||
|
||||
Two measurements had to stop being assumptions for furniture to fit
|
||||
the pipeline: the GROUND LINE is now read off the drawing (a building
|
||||
ends on the black threshold row it stands on; a table's legs stop two
|
||||
rows short of theirs, and extruding against the grid floated them in
|
||||
the air), and a template may name its PLOT (`depth`) when the matched
|
||||
grid runs past it onto the walkable floor the legs merely stand on.
|
||||
Both are identities for every existing building.
|
||||
|
||||
- **The Center couch has a backrest.** The couch is drawn from above --
|
||||
back-and-arm strip down the west side, cushions and seams on the east
|
||||
-- and rendered as one seat-high box. The new `backrest` class raises
|
||||
the drawn back strip to 12px over the 8px seat, in every Center and
|
||||
the Celadon Hotel. The man sitting on it keeps his seat: the figure
|
||||
anchor now scans under his card for the tallest authored upright (his
|
||||
cushion) instead of reading the corner tile, which is the backrest
|
||||
now.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Ledges are cliffs now.** A ledge used to be taken at face value: a 6px
|
||||
speed bump extruded out of a flat world. But the drawing is the game
|
||||
telling you about terrain -- the side you hop FROM is higher ground --
|
||||
so the world now has real elevation: everything above a hop-down edge
|
||||
stands one ledge-height (6px) up, the lip sits flush with the plateau it
|
||||
rims, and its south face is the cliff drop wearing the same cropped lip
|
||||
art it always wore. Stack ledges and the tiers stack; Pallet Town is the
|
||||
sea-level datum at 0, and the terrain tops out 22px up.
|
||||
- **Sprites ride at the height the art actually stands.** Class heights
|
||||
can now be overridden per tileset (a tileset entry's `heights`), and
|
||||
DOJO's lab tables use it: they are drawn 6px tall, not the default
|
||||
table's 12, so the starter balls sit exactly on the modelled tabletop
|
||||
-- and the volume-built north tables drop to the same height, keeping
|
||||
every table in the room level.
|
||||
- The Center PC's old rendering -- a 12px table box with the unit as a
|
||||
flat standee on it -- retires wherever the F04 template stamps; the
|
||||
pins stay only as the degradation path when the shape profile is
|
||||
absent.
|
||||
|
||||
The subtlety is that ledges do not enclose anything -- every one can be
|
||||
walked around through a gap, so a plateau flood fill would leak through
|
||||
it, meet itself across its own ledge line and conclude an area sits a
|
||||
tier above itself. So lib/Elevation.lua closes the lines before it
|
||||
fills anything. Ledge cells group into runs, and each run is extended
|
||||
along its own axis from either end -- across the gap the player detours
|
||||
through -- until it reaches something that closes it: unwalkable
|
||||
ground, another ledge, or the edge of the world. A run that finds
|
||||
nothing within reach is not a cliff anybody walks around and stays
|
||||
open. The cells that extension crosses become STEPS, and they are the
|
||||
one piece of sloped ground in the world: their tiles grade into 4px and
|
||||
2px treads, so walking a sealed gap climbs three crisp 2px risers where
|
||||
the cliff line crosses it.
|
||||
|
||||
With every line closed, an ordinary flood over the walkable ground cuts
|
||||
the world into areas -- and now they really are the areas enclosed by
|
||||
ledges. Those areas are then levelled against each other in whole
|
||||
tiers, by least squares over the area graph rather than by propagation:
|
||||
each lip votes "the area behind me is one tier over the area in front",
|
||||
areas meeting across ordinary trees or water cast a small
|
||||
same-ground vote, and a stray lip is outvoted by the run it disagrees
|
||||
with instead of tipping half a route. Because each area is a single
|
||||
variable the result is exactly FLAT -- these are plateaus, not a
|
||||
smoothed field. The solve is global and anchored on Pallet's own
|
||||
ground, so two connected maps never disagree about a seam; it runs once
|
||||
inside the build budget, in well under a second for all 43k cells of
|
||||
Kanto, and cuts them into 199 areas rising six tiers from Pallet at 0 to
|
||||
the Route 22/23 highlands at 42px.
|
||||
|
||||
Roughly three quarters of the ledges with standable ground on both
|
||||
sides come out as an exact one-tier drop. The rest -- and the ones
|
||||
whose high side is impassable mountain rock, where there is no plateau
|
||||
to stand on at all -- take the level of the ground around them, which
|
||||
reads as terrain rather than as a mistake, but they are the cases still
|
||||
worth an eye in-game.
|
||||
|
||||
Everything that stands on the ground rides it: characters and NPCs
|
||||
(including ghosts on neighbour maps), grass tufts, flowers, props,
|
||||
buildings (each on one flattened pad -- no terraced floorboards), tree
|
||||
hulls, battle arenas and their camera rig, cast shadows (the sun
|
||||
frustum grows by the tallest base), and the free-roam camera's focus,
|
||||
which eases after the player's ground height instead of pinning to the
|
||||
old flat plane. Cliff skirts fall out of the mesher's own
|
||||
neighbour-difference rule, banded in cell-local height so every crop
|
||||
the flat world drew is byte-identical there. Interiors and any map not
|
||||
connected to Pallet keep the classic flat reading, ledge bumps and all.
|
||||
## 1.4.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -174,6 +177,72 @@
|
||||
ever lands half-way between two pixels and changing a wavelength moves the
|
||||
speed with it.
|
||||
|
||||
- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is
|
||||
flat art blitted at whole pixels. This mode's world is real geometry seen
|
||||
through a perspective camera, and a polygon edge that lands at an angle
|
||||
across the pixel grid is the one place where a hard stair-step is not a
|
||||
stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against
|
||||
the sky, the leaning card of a character. At the shallow rungs, where the
|
||||
diorama reads most like a photograph of a model, they crawl as the camera
|
||||
drifts.
|
||||
|
||||
The row is SUPERSAMPLING: the whole pass renders into a canvas larger than
|
||||
the window and is folded back down at the end. The ladder is samples per
|
||||
display pixel, so 2X is a canvas root-two wider and taller and 4X one
|
||||
exactly twice the size -- an honest 2x2 box.
|
||||
|
||||
Two alternatives were tried against what this pass already is, and both
|
||||
lost:
|
||||
|
||||
- **MSAA** would have taken the water with it. The reflections read the
|
||||
frame's own depth buffer as a texture, and a multisampled depth
|
||||
attachment is not something a fragment shader in this dialect can sample.
|
||||
The row would have quietly switched the WATER row off.
|
||||
|
||||
- **An edge filter** (FXAA and its relatives) works from the finished
|
||||
colour alone, so it would be guessing where the edges are out of one
|
||||
sample per pixel -- inventing detail it never rendered, and unable to
|
||||
tell a geometry edge from the boundary between two texels of a tileset.
|
||||
|
||||
Rendering larger has neither problem, and nothing in the frame had to be
|
||||
taught about it: every pass already measures itself in the canvas it was
|
||||
handed, so the sky's dither, the water's ray march, the shadow lookups and
|
||||
the camera itself come out the same picture at a higher sample rate. It
|
||||
antialiases the geometry, the alpha-cut outline of a sprite card, the
|
||||
wireframe and the reflections at once, because none of them know it is
|
||||
happening.
|
||||
|
||||
And it softens the ARTWORK with them, which is worth saying plainly. A
|
||||
tileset texel out here is not a screen pixel, it is a quad in a perspective
|
||||
view, and its boundary crosses the pixel grid at the same arbitrary angle a
|
||||
roof ridge does -- so the fold averages across it exactly as it averages
|
||||
across the ridge. That is what an honest extra sample says about that
|
||||
pixel, and it is also the trade the row is: the diorama comes out smoother,
|
||||
not sharper. Which is why it is a row and not something that is simply on.
|
||||
|
||||
Two things are quoted in DISPLAY pixels rather than canvas ones and are
|
||||
multiplied up to match: the voxel wireframe's line width -- left alone it
|
||||
would fold down to half a line, so turning the smoothing up would appear to
|
||||
fade the grid out -- and the scale the overworld's FX closures draw at.
|
||||
|
||||
The fold is a shader rather than a scaled draw, because the void this pass
|
||||
renders into is a transparent BLACK: averaging a straight-alpha edge against
|
||||
it drags the colour toward black as well as toward transparent, and the
|
||||
engine's composite then multiplies by that alpha a second time. Every
|
||||
silhouette against the sky would have come out ringed with a dark fringe --
|
||||
the exact artefact the row exists to remove. So the taps are premultiplied
|
||||
before they are averaged and divided back out after.
|
||||
|
||||
The staged battle gets it too, on its own canvas: the arena is folded back
|
||||
to the window's pixel size before the depth-of-field pass and the HUDs go
|
||||
on, so the world is smoothed and the pics, panels and text box stay the
|
||||
chunky GB art they are.
|
||||
|
||||
OFF by default, and **FULL neither sets it nor takes the row away** -- it
|
||||
is the one row that is not a knob on the look but on what the look COSTS,
|
||||
and only the player knows what their machine can carry. No hotkey, for the
|
||||
same reason: it is set once, not flicked while walking.
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -274,6 +343,36 @@
|
||||
depth16 as the floor every GLES3 device can read. Refused all four, the
|
||||
reflections are lost and nothing else, exactly as before.
|
||||
|
||||
- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu,
|
||||
Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the
|
||||
middle of them.** Those back pics are drawn as OUTLINES: everything inside
|
||||
the ink is the lightest shade, the decoder keys that shade to nothing, and
|
||||
on hardware it did not matter because the field behind them was white too.
|
||||
|
||||
BattlePics already put that paper back by flooding the background inward and
|
||||
filling whatever it could not reach, and along the bottom of a figure it told
|
||||
a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a
|
||||
stride, left open for the world to show through). Right for a mon standing
|
||||
on the map -- but the pinned back pic is not on the map, it is on the text
|
||||
box with its feet on row 96, and there is white box under its lowest row
|
||||
rather than arena. Every one of those mons leaks out through an opening far
|
||||
too wide to read as a drain, so the flood walked straight up inside them.
|
||||
|
||||
A pic on the box is now told so, and its bottom edge seals: nothing reaches
|
||||
it from below at any width, and the rule stops being a heuristic -- paper is
|
||||
whatever the background cannot walk to from the left, the right or the top.
|
||||
Twelve of the game's 151 back pics turn on this; the other 139 come back
|
||||
byte-identical, and no front pic is touched at all.
|
||||
|
||||
**And a hole is filled with the pic's own paper rather than with white.**
|
||||
Shade 0 is only white while the pic is still grays, and pics arrive here
|
||||
after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK
|
||||
across the whole screen while the blackout text is up. A hardcoded white
|
||||
belly would have been the one lit thing on a blacked-out mon. The lightest
|
||||
shade still standing in the pic is that colour, and every one of the game's
|
||||
battler pics keeps at least one such pixel -- an eye, a highlight down a
|
||||
cheek -- so what goes back is the baked shade itself.
|
||||
|
||||
### Known
|
||||
|
||||
- Screen-space reflections can only reflect what is in the frame. A tree just
|
||||
|
||||
@@ -52,6 +52,7 @@ menu.
|
||||
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
|
||||
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
|
||||
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
|
||||
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
|
||||
|
||||
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
||||
|
||||
+191
-10
@@ -98,10 +98,6 @@ return {
|
||||
ground = 0,
|
||||
water = -2,
|
||||
void = 0,
|
||||
-- doubles as the terrain TIER: on the connected overworld the ground
|
||||
-- above a hop-down edge stands this many pixels up (lib/Elevation.lua)
|
||||
-- and the lip's box sinks by the same amount to sit flush as its rim,
|
||||
-- so retuning it retunes the cliffs with the faces that clothe them
|
||||
ledge = 6,
|
||||
fence = 10,
|
||||
sign = 12,
|
||||
@@ -114,6 +110,9 @@ return {
|
||||
bed = 7,
|
||||
stool = 8,
|
||||
counter = 8,
|
||||
-- the raised back band of low seating (the Center couch's back and
|
||||
-- arm strip): half again the 8px seat it rises over
|
||||
backrest = 12,
|
||||
table = 12,
|
||||
desk = 24,
|
||||
prop = 16,
|
||||
@@ -583,6 +582,12 @@ return {
|
||||
-- auto-extracting into standing prisms, and the ball/Pokedex
|
||||
-- sprites ride the table's authored height.
|
||||
table = { 41, 42, 57, 59, 78, 79 },
|
||||
-- These tables are drawn 6px tall (3px slab edge over 3px base --
|
||||
-- see the lab_table entry under `buildings`), not the default
|
||||
-- table's 12: the override keeps the volume-built north tables
|
||||
-- and the band-built starter table one height, and stands the
|
||||
-- ball/Pokedex sprites exactly on the top face of both.
|
||||
heights = { table = 6 },
|
||||
},
|
||||
|
||||
-- Red's room and the Copycat's room (one tileset). The detector reads
|
||||
@@ -703,14 +708,25 @@ return {
|
||||
-- one clean band, so the drawn front panel stands up and the
|
||||
-- counter top stays on top; at 12 they read as wall stubs
|
||||
counter = { 8, 10, 24, 25, 56, 90, 91,
|
||||
-- and the lounge couch with the man sitting on it.
|
||||
-- Same half-cell box: its bottom row (42/43, the front
|
||||
-- base) stands up as the couch's front, and the three
|
||||
-- rows above it -- cushion (38/39) and the man
|
||||
-- (36/37 head, 52/53 face) -- ride the top face in
|
||||
-- and the lounge couch's SEAT column with the man
|
||||
-- sitting on it. Same half-cell box: its bottom row
|
||||
-- (43, the front base) stands up as the couch's
|
||||
-- front, and the rows above it -- cushion (39) and
|
||||
-- the man (37 head, 53 face) -- ride the top face in
|
||||
-- drawn order, each exactly once. See the note below
|
||||
-- on why he cannot be stood upright.
|
||||
36, 37, 38, 39, 42, 43, 52, 53 },
|
||||
37, 39, 43, 53 },
|
||||
-- The couch's WEST tile column: the drawing's left strip is the
|
||||
-- couch's back and arm running north-south (the seat's cushions
|
||||
-- and seams fill the east column), so it rises over the 8px seat
|
||||
-- the way a couch back does instead of lying flush in the same
|
||||
-- box. Per-tile granularity puts the drawn ~6px strip plus a
|
||||
-- 2px sliver of cushion on the raised band -- invisible at tile
|
||||
-- scale, and the alternative is no backrest at all. The figure
|
||||
-- anchor scans for the tallest authored UPRIGHT under the man
|
||||
-- (see Structures.buildFigure), so he keeps sitting at seat
|
||||
-- height beside it.
|
||||
backrest = { 36, 38, 42, 52 },
|
||||
-- standing per-pixel props, black-outline segmented: the healing
|
||||
-- machines' screen tops (58/59/74/75, drawn above the pinned
|
||||
-- bodies so they stand ON them) and the PC (66/70/82/86), which
|
||||
@@ -3235,5 +3251,170 @@ return {
|
||||
slab = 4, frontEave = 4, ledge = nil,
|
||||
},
|
||||
},
|
||||
|
||||
DOJO = {
|
||||
-- F01: the starter-ball table in Oak's lab (one placement in the
|
||||
-- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the
|
||||
-- band pipeline, and the first drawing whose plot is smaller than
|
||||
-- its grid. Its 24 rows read: 0-15 the tabletop seen from above
|
||||
-- (black rim, white highlight course, grey field); 16-18 the top
|
||||
-- slab's own front edge, black/#555/black -- which is exactly
|
||||
-- what the rim treatment paints, so slab = 3 and those rows fold
|
||||
-- into the roof band instead of extruding under it; 19-21 the
|
||||
-- base band, corner feet and the inset dark panel between them.
|
||||
--
|
||||
-- The legs stand on open FLOOR: the measured ground line lands
|
||||
-- two rows short of the grid (see Buildings measure), and `depth`
|
||||
-- keeps the plot to the blocked cell row -- the grid's third row
|
||||
-- is the walkable cell the player faces the table from, matched
|
||||
-- so the flat leg art is claimed off the floor, not so the model
|
||||
-- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack
|
||||
-- covers the whole depth, nothing cycles, and roofCycle is
|
||||
-- unreachable behind it. The Poke Ball sprites ride the `table`
|
||||
-- pin's height (VoxelScene.groundAt reads the collision tile, not
|
||||
-- this model), so the tileset entry above overrides that height
|
||||
-- to the 6px this drawing actually stands.
|
||||
{
|
||||
id = "lab_table",
|
||||
tiles = {
|
||||
{ 41, 59, 59, 59, 59, 42 },
|
||||
{ 78, 57, 57, 57, 57, 79 },
|
||||
{ 88, 89, 89, 89, 89, 90 },
|
||||
},
|
||||
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
|
||||
slab = 3, frontEave = 0, ledge = nil, depth = 2,
|
||||
},
|
||||
-- F02: the computer desk on the lab's west side (OAKS_LAB cell
|
||||
-- 0,1) -- the one DESK-SET template: the pipeline's region
|
||||
-- classification at PART granularity (see Buildings
|
||||
-- deskSetModel). The desk is the sibling lab table (fascia rows
|
||||
-- 16-18, base 19-21); on it stand a monitor over its keyboard
|
||||
-- (left), a computer tower over a keyboard and mouse (middle),
|
||||
-- and a sheet of paper LYING FLAT (right). Upright parts anchor
|
||||
-- their drawn bottom row to the desk's top plane and wear their
|
||||
-- own drawn tops as lids; flat parts lie one voxel proud at
|
||||
-- drawn row = depth row -- the same 1:1 the tabletop itself is
|
||||
-- drawn with. The roof fields are inert (roofRows = 0 keeps the
|
||||
-- recess scan over the whole drawing, which is what sinks the
|
||||
-- monitor's screen and the tower's slots). Same drawing, same
|
||||
-- grid, stands in the Hall of Fame on the GYM atlas --
|
||||
-- registered there below.
|
||||
{
|
||||
id = "lab_computers",
|
||||
tiles = {
|
||||
{ 91, 92, 93, 94 },
|
||||
{ 54, 55, 85, 95 },
|
||||
{ 88, 89, 89, 90 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
|
||||
parts = {
|
||||
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
|
||||
facade = { 3, 10 }, depth = 4 }, -- the monitor
|
||||
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
|
||||
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
|
||||
facade = { 4, 10 }, depth = 6 }, -- the tower
|
||||
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
|
||||
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
|
||||
},
|
||||
},
|
||||
-- F03: the empty north table beside it (OAKS_LAB cell 2,1): the
|
||||
-- starter table's band table verbatim on a grid two tiles
|
||||
-- narrower.
|
||||
{
|
||||
id = "lab_table_small",
|
||||
tiles = {
|
||||
{ 41, 59, 59, 42 },
|
||||
{ 78, 57, 57, 79 },
|
||||
{ 88, 89, 89, 90 },
|
||||
},
|
||||
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
|
||||
slab = 3, frontEave = 0, ledge = nil, depth = 2,
|
||||
},
|
||||
},
|
||||
|
||||
POKECENTER = {
|
||||
-- F04: the PC in every Center's northeast corner (11
|
||||
-- placements; the Indigo Plateau lobby's twin is registered
|
||||
-- under MART below). The lab desk-set read again: a Mac-style
|
||||
-- unit drawn face-on -- white top band (rows 0-3), bezel, screen
|
||||
-- and drive slot (4-14) -- standing at the back of a low desk
|
||||
-- whose front face is rows 20-23; the drawn top around the unit
|
||||
-- is WHITE, which is what `lid` carries. The keyboard rows 17-19
|
||||
-- are drawn below the desk's 16px top span, so the flat part's
|
||||
-- `z` puts it at the desk's front edge. The old billboard pins
|
||||
-- for these tiles (66/70/82/86, desk 9/88) stay as the
|
||||
-- degradation path -- the claim neutralizes them wherever this
|
||||
-- template stamps.
|
||||
{
|
||||
id = "center_pc",
|
||||
tiles = {
|
||||
{ 66, 70 },
|
||||
{ 82, 86 },
|
||||
{ 9, 88 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
|
||||
parts = {
|
||||
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
|
||||
facade = { 4, 14 }, depth = 6 }, -- the unit
|
||||
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
|
||||
z = 13 }, -- keyboard
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
MART = {
|
||||
-- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the
|
||||
-- MART tileset shares the POKECENTER atlas, so this is the same
|
||||
-- drawing tile for tile. Same part table as the POKECENTER entry
|
||||
-- above.
|
||||
{
|
||||
id = "center_pc",
|
||||
tiles = {
|
||||
{ 66, 70 },
|
||||
{ 82, 86 },
|
||||
{ 9, 88 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
|
||||
parts = {
|
||||
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
|
||||
facade = { 4, 14 }, depth = 6 }, -- the unit
|
||||
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
|
||||
z = 13 }, -- keyboard
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
GYM = {
|
||||
-- F02 again: the Hall of Fame's recording machine is the lab's
|
||||
-- computer desk drawing, tile for tile, on the GYM atlas (one
|
||||
-- placement: HALL_OF_FAME cell 4,1). Same part table as the DOJO
|
||||
-- entry above.
|
||||
{
|
||||
id = "lab_computers",
|
||||
tiles = {
|
||||
{ 91, 92, 93, 94 },
|
||||
{ 54, 55, 85, 95 },
|
||||
{ 88, 89, 89, 90 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
|
||||
parts = {
|
||||
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
|
||||
facade = { 3, 10 }, depth = 4 }, -- the monitor
|
||||
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
|
||||
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
|
||||
facade = { 4, 10 }, depth = 6 }, -- the tower
|
||||
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
|
||||
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
-- Voxel world mode: anti-aliasing, by supersampling.
|
||||
--
|
||||
-- Everything else in this mod is flat art blitted at whole pixels; this one
|
||||
-- pass is real geometry seen through a perspective camera, and a polygon
|
||||
-- edge that lands at an angle across the pixel grid is the one place in the
|
||||
-- game where a hard stair-step is not a stylistic choice. A roof ridge, a
|
||||
-- ledge lip, a tree's silhouette against the sky and the leaning card of a
|
||||
-- character are all cut by an edge that has no reason to line up with
|
||||
-- anything, and at the shallow rungs -- where the diorama reads most like a
|
||||
-- photograph of a model -- they crawl as the camera drifts.
|
||||
--
|
||||
-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two
|
||||
-- reasons that both come out of what the pass already is:
|
||||
--
|
||||
-- MSAA would take the water with it. The reflections read the frame's own
|
||||
-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth
|
||||
-- attachment is not a thing a fragment shader in this dialect can sample.
|
||||
-- The row would have quietly switched the other row off.
|
||||
--
|
||||
-- An edge filter (FXAA and its relatives) works from the finished colour
|
||||
-- alone, and would be GUESSING where the edges are out of one sample per
|
||||
-- pixel -- inventing detail it never rendered, and unable to tell a
|
||||
-- geometry edge from the boundary between two texels of a tileset.
|
||||
--
|
||||
-- Rendering the pass larger and folding it back down has neither problem:
|
||||
-- the depth buffer stays an ordinary texture, every pass in the frame keeps
|
||||
-- working in the canvas it was handed, and the fold is an average of samples
|
||||
-- that were each rendered honestly. It antialiases everything at once --
|
||||
-- geometry, the alpha-cut outline of a sprite card, the wireframe, the
|
||||
-- water's ray march -- because none of them know it is happening.
|
||||
--
|
||||
-- Be clear about what "everything" means: the artwork softens too. A tileset
|
||||
-- texel out here is not a screen pixel, it is a quad in a perspective view,
|
||||
-- and its boundary crosses the pixel grid at the same arbitrary angle a roof
|
||||
-- ridge does -- so the fold averages across it exactly as it averages across
|
||||
-- the ridge. That is what an honest extra sample says about that pixel, and
|
||||
-- it is also the trade the row IS: the diorama comes out smoother, not
|
||||
-- sharper. Which is why this is a row and not something that is simply on.
|
||||
--
|
||||
-- What it costs is pixels, which is the whole of why this is a row and not
|
||||
-- something that is simply on: 2X is half again as many in each direction,
|
||||
-- 4X is twice, and the scene pass is the most expensive thing in the frame.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local AntiAlias = {}
|
||||
|
||||
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||
-- OPTIONS and the mod manager's own settings page for this mod
|
||||
AntiAlias.KEY = "aa"
|
||||
AntiAlias.LABEL = "AA"
|
||||
|
||||
-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads
|
||||
-- everywhere else, and the canvas scale each rung costs is its square root:
|
||||
-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the
|
||||
-- size. OFF is the default -- this is a cost knob, and a mod should not
|
||||
-- quietly spend four times the fill rate of the machine it lands on.
|
||||
AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL,
|
||||
{ 0, 2, 4 }, { "OFF", "2X", "4X" })
|
||||
|
||||
-- The scale the pass currently open was actually expanded by (see expand).
|
||||
-- 1 while there is no supersampling in force, which is also what every
|
||||
-- reader gets on a frame that never opened a pass at all.
|
||||
local live = 1
|
||||
|
||||
function AntiAlias.samples()
|
||||
return tonumber(AntiAlias.setting:get()) or 0
|
||||
end
|
||||
|
||||
-- What the row ASKS for. The scale in force is `factor()`, which is this
|
||||
-- clamped to what the driver will actually allocate.
|
||||
local function wanted()
|
||||
local n = AntiAlias.samples()
|
||||
if n <= 1 then return 1 end
|
||||
return math.sqrt(n)
|
||||
end
|
||||
|
||||
-- The biggest canvas this driver admits to, or nil where it will not say.
|
||||
-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty
|
||||
-- of hardware and every phone -- and a refused canvas is not a softer
|
||||
-- diorama, it is beginScene returning false and the whole mode falling back
|
||||
-- to the flat 2D path.
|
||||
local function textureLimit()
|
||||
if not (love.graphics and love.graphics.getSystemLimits) then return nil end
|
||||
local ok, limits = pcall(love.graphics.getSystemLimits)
|
||||
return (ok and limits and limits.texturesize) or nil
|
||||
end
|
||||
|
||||
-- The size to render `w` x `h` display pixels at, and the size everything
|
||||
-- inside the pass then measures itself in.
|
||||
--
|
||||
-- Also where `live` is set, which is why this must be called once per pass
|
||||
-- immediately before beginScene: the wireframe's line width and the FX
|
||||
-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be
|
||||
-- multiplied up into canvas ones, and the honest multiplier is the one this
|
||||
-- returned rather than the one the row asked for.
|
||||
function AntiAlias.expand(w, h)
|
||||
local s = wanted()
|
||||
local max = textureLimit()
|
||||
if max and max > 0 then
|
||||
-- clamped rather than abandoned: a window too big for 4X can usually
|
||||
-- still carry some of it, and half a rung of smoothing is worth more
|
||||
-- than a row that silently does nothing at that size
|
||||
s = math.min(s, max / math.max(1, w), max / math.max(1, h))
|
||||
end
|
||||
if not (s > 1.01) then
|
||||
live = 1
|
||||
return w, h
|
||||
end
|
||||
local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5)
|
||||
live = ew / math.max(1, w)
|
||||
return ew, eh
|
||||
end
|
||||
|
||||
-- The scale the open pass was expanded by; 1 when it was not.
|
||||
function AntiAlias.factor()
|
||||
return live
|
||||
end
|
||||
|
||||
-- ------- the fold
|
||||
--
|
||||
-- One target per pass (the free-roam world and the battle's arena are alive
|
||||
-- at different moments but reallocating on every battle entry and exit is
|
||||
-- what the scene canvas's own slots exist to avoid), reallocated only when
|
||||
-- that pass's DISPLAY size changes -- a window resize, or the row itself
|
||||
-- moving, which changes the source and not this.
|
||||
|
||||
local targets = {}
|
||||
|
||||
local function targetFor(slot, w, h)
|
||||
local t = targets[slot]
|
||||
if not (t and t.w == w and t.h == h) then
|
||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||
if not (ok and c) then return nil end
|
||||
-- nearest, like the canvas it stands in for: this one is composited a
|
||||
-- canvas pixel to a display pixel, and the smoothing has already happened
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||
t = { canvas = c, w = w, h = h }
|
||||
targets[slot] = t
|
||||
end
|
||||
return t.canvas
|
||||
end
|
||||
|
||||
-- The box filter, and the whole of why it is a shader rather than a scaled
|
||||
-- draw with linear filtering on.
|
||||
--
|
||||
-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at
|
||||
-- the rungs below FULL a good deal of the frame is still that. Averaging a
|
||||
-- straight-alpha edge against it drags the result toward black as well as
|
||||
-- toward transparent, and then the engine's own composite multiplies by that
|
||||
-- alpha a second time -- so every silhouette against the void would come out
|
||||
-- ringed with a dark fringe, which is exactly the artefact the row is here to
|
||||
-- remove.
|
||||
--
|
||||
-- So the taps are premultiplied before they are averaged and divided back out
|
||||
-- after, which is the arithmetic that makes an edge pixel mean "half covered
|
||||
-- by this colour" instead of "covered by half of this colour".
|
||||
--
|
||||
-- Four taps, half a source texel from the destination centre. At 4X those
|
||||
-- land dead on the four texel centres the destination pixel covers, so it is
|
||||
-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear
|
||||
-- fetch under each tap widens the box a little rather than missing samples.
|
||||
local SHADER = [[
|
||||
uniform vec2 tap; // half a SOURCE texel, in uv
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y));
|
||||
vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y));
|
||||
vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y));
|
||||
vec4 d = Texel(tex, tc + vec2( tap.x, tap.y));
|
||||
float al = (a.a + b.a + c.a + d.a) * 0.25;
|
||||
if (al <= 0.0) return vec4(0.0);
|
||||
vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a;
|
||||
return vec4(sum * 0.25 / al, al) * 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
|
||||
|
||||
-- Fold `canvas` down to `w` x `h` and hand back the result.
|
||||
--
|
||||
-- Returns the input untouched when there is nothing to fold -- the row is
|
||||
-- off, or the canvas already IS that size -- so a caller can run it
|
||||
-- unconditionally, and so can a headless test run. A target that would not
|
||||
-- allocate is the same answer: the pass is lost either way if this hands back
|
||||
-- something the wrong size, so it hands back the input and the frame draws at
|
||||
-- the size it was rendered.
|
||||
function AntiAlias.resolve(canvas, w, h, slot)
|
||||
if not canvas then return canvas end
|
||||
local ok, cw, ch = pcall(canvas.getDimensions, canvas)
|
||||
if not ok or (cw == w and ch == h) then return canvas end
|
||||
local target = targetFor(slot or "world", w, h)
|
||||
if not target then return canvas end
|
||||
|
||||
local sh = getShader()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
-- the scene canvas filters nearest for its usual 1:1 blit; the taps want
|
||||
-- linear, put back below so every other pass finds what it expects
|
||||
pcall(canvas.setFilter, canvas, "linear", "linear")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- replace, not alpha-blend: this is an image-processing copy, and the alpha
|
||||
-- the shader worked out has to land as itself rather than be composited
|
||||
-- against whatever the target held
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
if sh then
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch })
|
||||
end
|
||||
local drew = pcall(function()
|
||||
love.graphics.setCanvas(target)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch)
|
||||
end)
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||
return drew and target or canvas
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function AntiAlias.invalidate()
|
||||
for slot, t in pairs(targets) do
|
||||
if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||
targets[slot] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function AntiAlias.row()
|
||||
return AntiAlias.setting:row()
|
||||
end
|
||||
|
||||
return AntiAlias
|
||||
+2
-8
@@ -242,19 +242,13 @@ end
|
||||
-- Whether both mons would be in plain view from the battle camera.
|
||||
function BattleArena.clearance(map, arena)
|
||||
local BattleCam = V.require("BattleCam")
|
||||
-- rig and rays at the arena's OWN floor height: on solved terrain a
|
||||
-- fight on a plateau stands (and is judged) that many pixels up, or
|
||||
-- the raised ground itself would read as an obstacle over every mon
|
||||
local gy = heightAt(map, arena.player[1], arena.player[2])
|
||||
local ok, rig = pcall(BattleCam.rig, arena, gy)
|
||||
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], gy + hy, mark[2]) then
|
||||
return false
|
||||
end
|
||||
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
|
||||
+106
-25
@@ -57,6 +57,26 @@
|
||||
-- and they come back untouched because that is what their own shape says, not
|
||||
-- because they were special-cased.
|
||||
--
|
||||
-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a
|
||||
-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such
|
||||
-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own
|
||||
-- slot with its feet flush on the text box (BattleState.backPlacement pins
|
||||
-- row 96), so the only thing under its lowest row is white box. Nothing can
|
||||
-- reach it from below, whatever the opening's width, and the caller says so
|
||||
-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic
|
||||
-- and becomes exact: paper is whatever the background cannot walk to from the
|
||||
-- left, the right or the top.
|
||||
--
|
||||
-- That is the difference between a Pikachu that reads as a mon and one that
|
||||
-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong,
|
||||
-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is
|
||||
-- shade 0 and every one of them is keyed away, so the figure is a rim with the
|
||||
-- arena showing through it. Each one also has a wide opening along its bottom,
|
||||
-- which the drain cut correctly reads as a mouth and the sealed bottom
|
||||
-- correctly does not. Twelve of this game's 151 back pics turn on it; the
|
||||
-- other 139 come back byte-identical either way, because they had nothing
|
||||
-- under them the flood was getting in through.
|
||||
--
|
||||
-- The silhouette is untouched, so the mon still cuts cleanly against the
|
||||
-- world; only its insides stop being see-through.
|
||||
--
|
||||
@@ -71,14 +91,23 @@ 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" })
|
||||
-- Cached by the image the engine handed over, one table per bottom rule --
|
||||
-- the same pic answers differently sealed and unsealed, and a single table
|
||||
-- would hand the wrong twin back to whichever caller asked second. 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 function newCache()
|
||||
return {
|
||||
[false] = setmetatable({}, { __mode = "k" }),
|
||||
[true] = setmetatable({}, { __mode = "k" }),
|
||||
}
|
||||
end
|
||||
local cache = newCache()
|
||||
|
||||
-- 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.
|
||||
-- What an enclosed hole is filled with when the pic itself offers nothing
|
||||
-- better. 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.
|
||||
@@ -154,6 +183,42 @@ local function inkBounds(data, w, h)
|
||||
return x0, y0, x1, y1
|
||||
end
|
||||
|
||||
-- The colour the keyed-away shade would have had: the LIGHTEST colour still
|
||||
-- standing in the pic.
|
||||
--
|
||||
-- Pure white is only the right answer while the pic is still grays, and by the
|
||||
-- time it reaches here it usually is not. picImage hands a pic over AFTER the
|
||||
-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the
|
||||
-- whole screen while the blackout text is up -- and shade 0 travels with the
|
||||
-- rest. A white belly inside a blacked-out mon would be the one lit thing on a
|
||||
-- dark screen; inside a warm-palette mon it would be a cold patch the artist
|
||||
-- never drew.
|
||||
--
|
||||
-- So the paper is read off the pic rather than assumed, which needs shade 0 to
|
||||
-- have survived somewhere in it. It always has: every one of this game's 151
|
||||
-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a
|
||||
-- cheek, the white of an eye -- because only the shade-0 pixels the decoder
|
||||
-- could reach were keyed. So what comes back is the baked shade 0 itself, not
|
||||
-- an approximation of it, and it tracks every palette the engine picks without
|
||||
-- being told which one that was.
|
||||
--
|
||||
-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps
|
||||
-- all three channels monotonically, so lightest by sum is lightest full stop.
|
||||
local function paperColor(data, x0, y0, x1, y1)
|
||||
local best, pr, pg, pb = -1, nil, nil, nil
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local r, g, b, a = data:getPixel(x, y)
|
||||
if a > CUT then
|
||||
local lum = r + g + b
|
||||
if lum > best then best, pr, pg, pb = lum, r, g, b end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best < 0 then return nil end
|
||||
return pr, pg, pb
|
||||
end
|
||||
|
||||
-- The widest opening along the bottom of a figure that still counts as a drain
|
||||
-- rather than a mouth. See the header for the measurements either side of it.
|
||||
BattlePics.DRAIN = 6
|
||||
@@ -161,7 +226,9 @@ BattlePics.DRAIN = 6
|
||||
-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
|
||||
-- the edges of the artwork's box: the left, the right and the top whole, and
|
||||
-- along the bottom only those openings wide enough to be background rather
|
||||
-- than the underside of a figure the drawing ran out of.
|
||||
-- than the underside of a figure the drawing ran out of -- or none of them at
|
||||
-- all, for a pic whose feet are on the text box and which therefore has
|
||||
-- nothing behind its lowest row to let in.
|
||||
--
|
||||
-- Confined to the box as well as seeded from it, so the empty frame under a
|
||||
-- short pic cannot walk around a sealed drain and come back up through it.
|
||||
@@ -169,7 +236,7 @@ BattlePics.DRAIN = 6
|
||||
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
|
||||
-- pixels and a keyed-out background is most of them, which is a deeper call
|
||||
-- chain than is worth risking for no gain.
|
||||
local function markOutside(data, w, h, x0, y0, x1, y1)
|
||||
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local outside = {}
|
||||
local stack, top = {}, 0
|
||||
local function clear(x, y)
|
||||
@@ -191,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1)
|
||||
push(x1, y)
|
||||
end
|
||||
-- the bottom, run by run: a wide one is the gap between two legs and lets
|
||||
-- the world through, a narrow one is where a belly ran out and is sealed
|
||||
local x = x0
|
||||
while x <= x1 do
|
||||
if clear(x, y1) then
|
||||
local from = x
|
||||
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||
if (x - from) > BattlePics.DRAIN then
|
||||
for k = from, x - 1 do push(k, y1) end
|
||||
-- the world through, a narrow one is where a belly ran out and is sealed.
|
||||
-- Skipped whole for a pic on the box, where even the widest of them has
|
||||
-- white paper behind it rather than arena.
|
||||
if not sealBottom then
|
||||
local x = x0
|
||||
while x <= x1 do
|
||||
if clear(x, y1) then
|
||||
local from = x
|
||||
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||
if (x - from) > BattlePics.DRAIN then
|
||||
for k = from, x - 1 do push(k, y1) end
|
||||
end
|
||||
else
|
||||
x = x + 1
|
||||
end
|
||||
else
|
||||
x = x + 1
|
||||
end
|
||||
end
|
||||
while top > 0 do
|
||||
@@ -219,9 +290,15 @@ 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)
|
||||
--
|
||||
-- sealBottom for a pic pinned to the text box rather than standing on the map:
|
||||
-- see the header. A caller that does not say defaults to the map, which is
|
||||
-- where all but one of this mod's pics are.
|
||||
function BattlePics.filled(img, sealBottom)
|
||||
if not img then return img end
|
||||
local hit = cache[img]
|
||||
sealBottom = sealBottom and true or false
|
||||
local slot = cache[sealBottom]
|
||||
local hit = slot[img]
|
||||
if hit ~= nil then return hit or img end
|
||||
|
||||
local made = nil
|
||||
@@ -231,8 +308,12 @@ function BattlePics.filled(img)
|
||||
local w, h = data:getDimensions()
|
||||
local x0, y0, x1, y1 = inkBounds(data, w, h)
|
||||
if not x0 then return end -- a pic with nothing drawn in it
|
||||
local outside = markOutside(data, w, h, x0, y0, x1, y1)
|
||||
local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local fill = BattlePics.FILL
|
||||
local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
|
||||
local fr = pr or fill[1]
|
||||
local fg = pg or fill[2]
|
||||
local fb = pb or fill[3]
|
||||
local changed = false
|
||||
-- only inside the box: everything beyond it is frame the artist never
|
||||
-- reached, and filling that would put the mon in a white rectangle
|
||||
@@ -242,7 +323,7 @@ function BattlePics.filled(img)
|
||||
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])
|
||||
data:setPixel(x, y, fr, fg, fb, fill[4])
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
@@ -255,12 +336,12 @@ function BattlePics.filled(img)
|
||||
made = out
|
||||
end)
|
||||
|
||||
cache[img] = (ok and made) or false
|
||||
slot[img] = (ok and made) or false
|
||||
return made or img
|
||||
end
|
||||
|
||||
function BattlePics.invalidate()
|
||||
cache = setmetatable({}, { __mode = "k" })
|
||||
cache = newCache()
|
||||
end
|
||||
|
||||
return BattlePics
|
||||
|
||||
+12
-2
@@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -427,7 +428,16 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- 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
|
||||
--
|
||||
-- AA, if the row asks for it, renders it larger still and folds it back
|
||||
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
|
||||
-- the lens was widened by the window's RATIO to the letterbox and the
|
||||
-- rig solved in the GB's own frame, so a bigger canvas is more samples
|
||||
-- of the identical shot -- which is why the pins below still measure in
|
||||
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
|
||||
-- folded canvas afterwards, stay the chunky GB art they are.
|
||||
local rw, rh = AntiAlias.expand(pw, ph)
|
||||
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
|
||||
return
|
||||
end
|
||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||
@@ -500,7 +510,7 @@ function BattleScene.render(state, arena, textures, token)
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
local canvas = Voxel3D.endScene()
|
||||
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
|
||||
if not canvas then return end
|
||||
|
||||
local vp = Voxel3D.vp
|
||||
|
||||
+184
-17
@@ -190,7 +190,24 @@ local function measure(sp, t)
|
||||
top[x] = r
|
||||
end
|
||||
|
||||
local wallH = H - roofRows
|
||||
-- The drawing's own ground line: the row after the last drawn one. A
|
||||
-- building ends on the black threshold row it stands on (ground == H),
|
||||
-- but furniture is drawn standing on open floor -- the lab table's
|
||||
-- legs stop two rows short of its grid -- and extruding against H
|
||||
-- would float it that far above its own plot.
|
||||
local ground = roofRows
|
||||
for sy = H - 1, roofRows, -1 do
|
||||
local drawn = false
|
||||
for sx = 0, W - 1 do
|
||||
if sp.inside[sy * W + sx] then drawn = true break end
|
||||
end
|
||||
if drawn then
|
||||
ground = sy + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local wallH = ground - roofRows
|
||||
local ytop = wallH - 1 + t.slab
|
||||
|
||||
-- Side faces must not come out as slabs of outline black: where the
|
||||
@@ -279,20 +296,175 @@ local function measure(sp, t)
|
||||
-- sprite taller than its footprint -- the tower's 16-row drawing
|
||||
-- stands on the 8 rows of it that are actually on the map, and D = H
|
||||
-- would have pushed its body 64px south into the town plaza.
|
||||
return { top = top, ytop = ytop, D = #t.tiles * 8,
|
||||
-- `depth` (in tile rows) names the plot when the grid runs PAST it
|
||||
-- onto ground the drawing merely stands its legs on: the lab table's
|
||||
-- third row is the walkable cell the player faces it from, and the
|
||||
-- full-grid depth would stand the model in their path.
|
||||
return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8,
|
||||
ground = ground,
|
||||
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- build --
|
||||
|
||||
-- A desk with separately-classified objects on it (a template's `parts`
|
||||
-- list): the methodology's region classification at part granularity.
|
||||
-- Upright parts anchor their drawn bottom row to the desk's top plane
|
||||
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
|
||||
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
|
||||
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
|
||||
-- the drawing is its position ON the desk. The desk is the lab-table
|
||||
-- slab + base; its lid is the one synthesized surface in the model
|
||||
-- (the objects cover every drawn pixel of the tabletop), continued
|
||||
-- from the sibling tables' pattern in the drawing's own shades.
|
||||
-- tools/building_voxels.py `build_desk_set` is the reference twin.
|
||||
local function deskSetModel(sp, pr, t)
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local ground = pr.ground
|
||||
local col, inside = sp.col, sp.inside
|
||||
local vox = {}
|
||||
local function key(x, y, z) return (y * D + z) * W + x end
|
||||
local function put(x, y, z, i) vox[key(x, y, z)] = i end
|
||||
|
||||
-- de-outline walk bounded to the part, so a part's side faces show
|
||||
-- its own material and never the neighbour's (the sprite-wide walk
|
||||
-- the facade path uses would cross the black seam between units)
|
||||
local function interiorAt(sx, sy, lo, hi)
|
||||
local i = sy * W + sx
|
||||
if col[i] ~= BLACK then return sx end
|
||||
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
|
||||
for d = 1, 3 do
|
||||
local nx = sx + step * d
|
||||
if nx >= lo and nx <= hi then
|
||||
local ni = sy * W + nx
|
||||
if inside[ni] and col[ni] ~= BLACK then return nx end
|
||||
end
|
||||
end
|
||||
return sx
|
||||
end
|
||||
|
||||
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
|
||||
local b0, b1 = t.desk.base[1], t.desk.base[2]
|
||||
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
||||
|
||||
-- the base band, extruded exactly like every lab table's
|
||||
for sy = b0, b1 do
|
||||
Budget.tick()
|
||||
local y = ground - 1 - sy
|
||||
for sx = 0, W - 1 do
|
||||
if inside[sy * W + sx] then
|
||||
local ix = interiorAt(sx, sy, 0, W - 1)
|
||||
for z = 0, D - 1 do
|
||||
local px = (z == 0 or z == D - 1) and sx or ix
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for i in pairs(pr.recess) do
|
||||
local sy = math.floor(i / W)
|
||||
if sy >= b0 and sy <= b1 then
|
||||
vox[key(i % W, ground - 1 - sy, D - 1)] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- the slab: fascia rows wrap every side; the lid continues the
|
||||
-- sibling tables' top -- black rim, white highlight courses along
|
||||
-- the north and west, grey field
|
||||
for sy = f0, f1 do
|
||||
Budget.tick()
|
||||
local y = plane - 1 - (sy - f0)
|
||||
for sx = 0, W - 1 do
|
||||
for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
|
||||
end
|
||||
end
|
||||
local field = t.desk.lid == "white" and WHITE or GREY
|
||||
for sx = 0, W - 1 do
|
||||
for z = 0, D - 1 do
|
||||
local shade = field
|
||||
if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
|
||||
shade = BLACK
|
||||
elseif sx == 1 or z == 1 then
|
||||
shade = WHITE
|
||||
end
|
||||
put(sx, plane - 1, z, pr.shadeTexel[shade])
|
||||
end
|
||||
end
|
||||
|
||||
local ytop = plane
|
||||
for _, p in ipairs(t.parts) do
|
||||
Budget.tick()
|
||||
local x0, x1 = p.x[1], p.x[2]
|
||||
if p.kind == "flat" then
|
||||
-- drawn row = depth row by default; `z` renames the origin when
|
||||
-- the flat sits below the desk's own drawn top span (the Center
|
||||
-- PC's keyboard)
|
||||
local r0 = p.rows[1]
|
||||
local z0 = p.z or r0
|
||||
for sy = r0, p.rows[2] do
|
||||
local z = z0 + (sy - r0)
|
||||
if z >= 0 and z < D then
|
||||
for sx = x0, x1 do
|
||||
if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local tr0, tr1 = p.top[1], p.top[2]
|
||||
local fr0, fr1 = p.facade[1], p.facade[2]
|
||||
local pd = p.depth
|
||||
local ytp = plane + (fr1 - fr0)
|
||||
if ytp > ytop then ytop = ytp end
|
||||
for sx = x0, x1 do
|
||||
-- the lid: the part's drawn top laid across its depth from the
|
||||
-- back, last row continuing forward; the front lid row is the
|
||||
-- facade's own top row -- the drawn front-top edge
|
||||
for z = 0, pd - 1 do
|
||||
local front = z == pd - 1
|
||||
local sy = front and fr0 or math.min(tr0 + z, tr1)
|
||||
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
|
||||
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
|
||||
if ok then
|
||||
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
|
||||
end
|
||||
end
|
||||
-- the body: facade rows anchored to the desk's top plane
|
||||
for sy = fr0 + 1, fr1 do
|
||||
local y = plane + (fr1 - sy)
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local ix = interiorAt(sx, sy, x0, x1)
|
||||
for z = 0, pd - 1 do
|
||||
if z == pd - 1 then
|
||||
if not pr.recess[i] then put(sx, y, z, i) end
|
||||
elseif z == 0 then
|
||||
put(sx, y, z, i)
|
||||
else
|
||||
put(sx, y, z, sy * W + ix)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return { at = function(x, y, z)
|
||||
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
|
||||
return vox[key(x, y, z)]
|
||||
end,
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||
-- are trimmed to its underside so nothing pokes through the surface.
|
||||
local function model(sp, pr, t)
|
||||
if t.parts then return deskSetModel(sp, pr, t) end
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
local top, ytop = pr.top, pr.ytop
|
||||
local top, ytop, ground = pr.top, pr.ytop, pr.ground
|
||||
|
||||
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
|
||||
-- columns undrawn in the roof band; they carry no roof at all, and the
|
||||
@@ -367,16 +539,18 @@ local function model(sp, pr, t)
|
||||
|
||||
-- the awning: the band juts two voxels past the walls, front and back
|
||||
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
|
||||
local sy = H - 1 - y
|
||||
local sy = ground - 1 - y
|
||||
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
||||
return sy * W + x
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the facade, extruded straight back over the footprint
|
||||
-- the facade, extruded straight back over the footprint. Rows map
|
||||
-- against the measured ground line, not the grid's last row: the two
|
||||
-- differ only for furniture standing on open floor (see measure).
|
||||
if z < 0 or z >= D then return nil end
|
||||
local sy = H - 1 - y
|
||||
local sy = ground - 1 - y
|
||||
local i = sy * W + x
|
||||
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
||||
-- the drawing's last row is the ground the building stands on, so
|
||||
@@ -701,19 +875,12 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
vote(tx + bw, ty + r)
|
||||
end
|
||||
|
||||
-- One building, ONE base: the model is rigid, so it stands at the
|
||||
-- elevation under its door row and the pad beneath is flattened to
|
||||
-- match -- a house near a ramp must not have terraced floorboards.
|
||||
local my = S.base
|
||||
and S.base[keyOf(tx + math.floor(bw / 2), ty + bh - 1)] or 0
|
||||
|
||||
for r = 0, bh - 1 do
|
||||
for c = 0, bw - 1 do
|
||||
local k = keyOf(tx + c, ty + r)
|
||||
S.shapeAt[k] = shape
|
||||
S.skip[k] = true
|
||||
S.ground[k] = best or false
|
||||
if S.base then S.base[k] = my ~= 0 and my or nil end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -721,10 +888,10 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
local out = S.objectQuads
|
||||
for _, q in ipairs(quads) do
|
||||
out[#out + 1] = {
|
||||
{ q[1][1] + mx, q[1][2] + my, q[1][3] + mz },
|
||||
{ q[2][1] + mx, q[2][2] + my, q[2][3] + mz },
|
||||
{ q[3][1] + mx, q[3][2] + my, q[3][3] + mz },
|
||||
{ q[4][1] + mx, q[4][2] + my, q[4][3] + mz },
|
||||
{ q[1][1] + mx, q[1][2], q[1][3] + mz },
|
||||
{ q[2][1] + mx, q[2][2], q[2][3] + mz },
|
||||
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
|
||||
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
-- placements only ever scan the BODY, so a building is always this
|
||||
-- map's own structure: the mesher's edge keep-rules must not eat
|
||||
|
||||
+33
-69
@@ -239,22 +239,13 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
local atlasW = tileset.imageWidth or (perRow * 8)
|
||||
local atlasH = tileset.imageHeight or 48
|
||||
|
||||
-- The terrain's base elevation under a tile (0 on a map without a
|
||||
-- solved field -- every interior), and the ABSOLUTE height of what
|
||||
-- stands there: base + the shape's own extrusion. Side faces are
|
||||
-- derived from neighbour height differences, so once every height is
|
||||
-- measured from the same datum the cliff skirt under a raised cell
|
||||
-- falls out of the same band loop that has always clothed walls.
|
||||
local baseAt = S.base and function(k) return S.base[k] or 0 end
|
||||
or function() return 0 end
|
||||
|
||||
local function heightAt(tx, ty)
|
||||
local k = keyOf(tx, ty)
|
||||
if S.skip[k] then return baseAt(k) end
|
||||
if S.skip[k] then return 0 end
|
||||
local run = S.runs[k]
|
||||
if run then return baseAt(k) + run.h end
|
||||
if run then return run.h end
|
||||
local s = S.shapeAt[k]
|
||||
return baseAt(k) + (s and s.h or 0)
|
||||
return s and s.h or 0
|
||||
end
|
||||
|
||||
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
|
||||
@@ -353,17 +344,13 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- blocks half the sky, so the closer a voxel sits to it the less ambient
|
||||
-- light reaches it -- which is what plants a prop on the floor instead
|
||||
-- of leaving it looking pasted over the top.
|
||||
-- `floor` is the terrain base the prop stands on: contact darkening
|
||||
-- measures height above the prop's OWN ground, not above the world
|
||||
-- datum, or every plant on a plateau would lose its feet.
|
||||
local aoProp = { 0, 0, 0, 0 }
|
||||
local function groundShades(c, shade, floor)
|
||||
local function groundShades(c, shade)
|
||||
if type(shade) == "table" then return shade end
|
||||
floor = floor or 0
|
||||
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
|
||||
if math.min(y1, y2, y3, y4) - floor >= AO_RISE then return shade end
|
||||
if math.min(y1, y2, y3, y4) >= AO_RISE then return shade end
|
||||
for i = 1, 4 do
|
||||
local t = (c[i][2] - floor) / AO_RISE
|
||||
local t = c[i][2] / AO_RISE
|
||||
aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t)))
|
||||
end
|
||||
return aoProp
|
||||
@@ -469,32 +456,28 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- prebuilt prism quads (appended below) carry the art
|
||||
local g = S.ground[k]
|
||||
if g then
|
||||
local b = baseAt(k)
|
||||
topQuad(tx * 8, ty * 8, b, g, 1)
|
||||
-- the claimed tile is still ground at its base, and water next
|
||||
topQuad(tx * 8, ty * 8, 0, g, 1)
|
||||
-- the claimed tile is still ground at height 0, and water next
|
||||
-- door still recesses below it: without the same below-ground
|
||||
-- side bands ordinary ground emits, the two-pixel shoreline
|
||||
-- face is a slit into the sky behind the mesh -- which is
|
||||
-- exactly what a building plot or a sign standing at the
|
||||
-- waterline showed. Same bands, cut from the synthesized
|
||||
-- ground's own art. Bands run in CELL-LOCAL height (world
|
||||
-- minus base), so the crop is the one the flat world always
|
||||
-- drew, translated up with the terrain.
|
||||
-- ground's own art
|
||||
for _, side in ipairs(SIDES) do
|
||||
local nh = heightAt(tx + side[1], ty + side[2])
|
||||
if nh < b then
|
||||
if nh < 0 then
|
||||
local d = side[3]
|
||||
local lat = LATERAL[d]
|
||||
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
|
||||
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
|
||||
local nl = nh - b
|
||||
for band = math.floor(nl / 8), -1 do
|
||||
local ly0 = math.max(nl, band * 8)
|
||||
local ly1 = math.min(0, band * 8 + 8)
|
||||
if ly1 > ly0 then
|
||||
sideQuad(d, tx * 8, ty * 8, b + ly0, b + ly1, g,
|
||||
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
|
||||
sideShades(hl, hr, b + ly0, b + ly1, ly0 <= nl,
|
||||
for band = math.floor(nh / 8), -1 do
|
||||
local y0 = math.max(nh, band * 8)
|
||||
local y1 = math.min(0, band * 8 + 8)
|
||||
if y1 > y0 then
|
||||
sideQuad(d, tx * 8, ty * 8, y0, y1, g,
|
||||
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
|
||||
sideShades(hl, hr, y0, y1, y0 <= nh,
|
||||
Voxel3D.FACE_SHADE[d]))
|
||||
end
|
||||
end
|
||||
@@ -503,11 +486,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
end
|
||||
elseif s then
|
||||
local run = S.runs[k]
|
||||
local b = baseAt(k)
|
||||
-- h is ABSOLUTE (base + extrusion), matching heightAt; hLocal is
|
||||
-- the extrusion alone, which is the space the art bands live in
|
||||
local hLocal = run and run.h or s.h
|
||||
local h = b + hLocal
|
||||
local h = run and run.h or s.h
|
||||
local x0, z0 = tx * 8, ty * 8
|
||||
|
||||
-- top face. A roofed volume gets a GABLE segment: the roof rises
|
||||
@@ -523,10 +502,9 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- everything else its own art.
|
||||
if run and run.rise > 0 then
|
||||
local mid = run.extent / 2
|
||||
local runTop = b + run.h -- the facade top, absolute
|
||||
local function gableH(d) -- d = rows north of the south eave
|
||||
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
|
||||
return runTop + run.rise * math.max(0, math.min(1, t))
|
||||
return run.h + run.rise * math.max(0, math.min(1, t))
|
||||
end
|
||||
local d0 = run.front - ty -- rows from the south edge
|
||||
local hS = gableH(d0)
|
||||
@@ -537,13 +515,13 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
math.floor((1 - rel) * run.roofRows))
|
||||
local roofTile = map:tileAt(tx, run.north + idx)
|
||||
local swY, seY, neY, nwY = hS, hS, hN, hN
|
||||
if heightAt(tx - 1, ty) < runTop then -- west flank: hip
|
||||
swY = math.max(runTop, hS - 8)
|
||||
nwY = math.max(runTop, hN - 8)
|
||||
if heightAt(tx - 1, ty) < run.h then -- west flank: hip
|
||||
swY = math.max(run.h, hS - 8)
|
||||
nwY = math.max(run.h, hN - 8)
|
||||
end
|
||||
if heightAt(tx + 1, ty) < runTop then -- east flank: hip
|
||||
seY = math.max(runTop, hS - 8)
|
||||
neY = math.max(runTop, hN - 8)
|
||||
if heightAt(tx + 1, ty) < run.h then -- east flank: hip
|
||||
seY = math.max(run.h, hS - 8)
|
||||
neY = math.max(run.h, hN - 8)
|
||||
end
|
||||
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
|
||||
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
|
||||
@@ -580,7 +558,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
break
|
||||
end
|
||||
end
|
||||
local row = math.min(ty, front - math.floor(hLocal / 8))
|
||||
local row = math.min(ty, front - math.floor(h / 8))
|
||||
if row < north then
|
||||
-- the whole run folded onto the face: top with the drawn
|
||||
-- row just above it when that row is furniture too (a
|
||||
@@ -605,14 +583,6 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||
-- heights [8k, 8k+8) and shows one full tile of art; a partial
|
||||
-- band crops the art rows to match, so nothing ever stretches.
|
||||
-- Bands count in CELL-LOCAL height (world minus this cell's base):
|
||||
-- the fold starts at the cell's own feet wherever the terrain
|
||||
-- raised them, and the crop a 6px lip face has always worn stays
|
||||
-- byte-identical on a flat map. Negative bands are the SKIRT a
|
||||
-- raised cell shows a lower neighbour -- terrain that had no face
|
||||
-- at all before elevation -- and they wear the cell's own art
|
||||
-- from its top row down, the same convention the recessed-water
|
||||
-- shoreline bands established below zero.
|
||||
for _, side in ipairs(SIDES) do
|
||||
local nh = heightAt(tx + side[1], ty + side[2])
|
||||
if nh < h then
|
||||
@@ -623,11 +593,9 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
local lat = LATERAL[d]
|
||||
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
|
||||
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
|
||||
local nl = nh - b
|
||||
for band = math.floor(nl / 8), math.ceil(hLocal / 8) - 1 do
|
||||
local ly0 = math.max(nl, band * 8)
|
||||
local ly1 = math.min(hLocal, band * 8 + 8)
|
||||
local y0, y1 = b + ly0, b + ly1
|
||||
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do
|
||||
local y0 = math.max(nh, band * 8)
|
||||
local y1 = math.min(h, band * 8 + 8)
|
||||
if y1 > y0 then
|
||||
local src, shade = tile, Voxel3D.FACE_SHADE[d]
|
||||
if run then
|
||||
@@ -671,7 +639,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
end
|
||||
end
|
||||
sideQuad(d, x0, z0, y0, y1, src,
|
||||
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
|
||||
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
|
||||
sideShades(hl, hr, y0, y1, y0 <= nh, shade))
|
||||
end
|
||||
end
|
||||
@@ -748,11 +716,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- the neighbour will ever draw that geometry
|
||||
if q.own or outwardOnEdge(q, x0, z0, x1, z1)
|
||||
or keepQuad(x0, z0, x1, z1) then
|
||||
local fl = S.base
|
||||
and baseAt(keyOf(math.floor(q[1][1] / 8),
|
||||
math.floor(q[1][3] / 8))) or 0
|
||||
push({ q[1], q[2], q[3], q[4] }, quadUV(q),
|
||||
groundShades(q, q.shade, fl))
|
||||
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -796,7 +760,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
for i = 1, 4 do
|
||||
local c, s2 = q[i], sc[i]
|
||||
s2[1] = c[1] + mx
|
||||
s2[2] = c[2] + (st.my or 0)
|
||||
s2[2] = c[2]
|
||||
s2[3] = c[3] + mz
|
||||
end
|
||||
local ok = keepAll
|
||||
@@ -808,7 +772,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
ok = keepQuad(x0, z0, x1, z1)
|
||||
end
|
||||
if ok then
|
||||
push(sc, quadUV(q), groundShades(sc, q.shade, st.my))
|
||||
push(sc, quadUV(q), groundShades(sc, q.shade))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+32
-1
@@ -104,6 +104,31 @@ function OverworldBattle.backPinned()
|
||||
return OverworldBattle.backSetting:get() and true or false
|
||||
end
|
||||
|
||||
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
|
||||
-- text box, rather than geometry standing out on the map.
|
||||
--
|
||||
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
|
||||
-- that holds the slot until "Go!" -- because that is the only pic this mod
|
||||
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
|
||||
-- tile whichever mode is on, and with the mode off the player's side is one
|
||||
-- too, so both of those keep the open bottom that lets the arena through a
|
||||
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
|
||||
-- behind its lowest row, so its bottom edge seals.
|
||||
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
|
||||
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
|
||||
-- it that way for the whole render -- during which the pic layer runs, and
|
||||
-- picImage asks this. A nil test passes a `false` straight through to the
|
||||
-- index below, and the error comes out of sideTexture into the pcall that
|
||||
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
|
||||
-- simply is not there.
|
||||
function OverworldBattle.pinnedPic(battle, img)
|
||||
if not (battle and img) then return false end
|
||||
if not OverworldBattle.backPinned() then return false end
|
||||
if img == battle.playerBackPic then return true end
|
||||
local player = battle.player
|
||||
return (player and img == player.sprite) and true or false
|
||||
end
|
||||
|
||||
-- ------- both mons face you
|
||||
--
|
||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||
@@ -831,11 +856,17 @@ function OverworldBattle.install()
|
||||
-- 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.
|
||||
--
|
||||
-- The pinned pic is told that its feet are on the box, which is what lets
|
||||
-- the pale-bodied back sprites be filled at all: their bellies leak out
|
||||
-- through an opening too wide to read as a drain, and only the box under
|
||||
-- them settles that it is not a hole. Passed the pre-bake image, because
|
||||
-- that is the one the battle holds a reference to.
|
||||
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)
|
||||
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
||||
end
|
||||
|
||||
-- While a billboard texture is being rendered both pics are put in the same
|
||||
|
||||
+2
-6
@@ -29,7 +29,6 @@ local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Elevation = V.require("Elevation")
|
||||
|
||||
local ShadowMap = {}
|
||||
|
||||
@@ -296,10 +295,7 @@ local function fit(cx, cy, vw, vh)
|
||||
local f = sunDir()
|
||||
local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 })
|
||||
|
||||
-- the tallest thing that can cast: the fixed geometry ceiling plus the
|
||||
-- terrain base it may be standing on (0 wherever no elevation solved)
|
||||
local top = ShadowMap.HEIGHT + Elevation.maxBase()
|
||||
local reach = top
|
||||
local reach = ShadowMap.HEIGHT
|
||||
* math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24
|
||||
local north = groundReach(vh)
|
||||
-- the view widens with distance, so the far ground spans more than the
|
||||
@@ -307,7 +303,7 @@ local function fit(cx, cy, vw, vh)
|
||||
-- frustum's true spread and costs a good deal less resolution
|
||||
local spread = north * 0.5
|
||||
local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach }
|
||||
local ys = { -32, top } -- -32 covers recessed water
|
||||
local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water
|
||||
local zs = { cy - north, cy + vh / 2 + reach }
|
||||
|
||||
local l, r, b, t, zn, zf
|
||||
|
||||
+36
-81
@@ -49,7 +49,6 @@ local Map = require("src.world.Map")
|
||||
local Buildings = V.require("Buildings")
|
||||
local TileShape = V.require("TileShape")
|
||||
local Budget = V.require("BuildBudget")
|
||||
local Elevation = V.require("Elevation")
|
||||
|
||||
local Structures = {}
|
||||
|
||||
@@ -209,38 +208,6 @@ function Structures.forMap(map)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the terrain's base elevation under every tile ----
|
||||
--
|
||||
-- Elevation solves the ledge-bounded terrain once, globally, at CELL
|
||||
-- granularity (see lib/Elevation.lua); here it lands per TILE so the
|
||||
-- mesher and every quad emitter below read one table. Two wrinkles:
|
||||
--
|
||||
-- * a ledge tile's box KEEPS its authored height but sinks by it --
|
||||
-- base + h then puts the lip's top flush with the high plateau it
|
||||
-- is the rim of, and its exposed south face is exactly the tier
|
||||
-- drop wearing the same cropped lip art it always wore;
|
||||
-- * ring tiles read through baseAtTile's edge clamp, so the border
|
||||
-- apron continues the body's elevation instead of cliffing to 0.
|
||||
--
|
||||
-- Maps without a field (every interior) get no table at all, and every
|
||||
-- consumer's `S.base and ...` guard keeps the classic flat path.
|
||||
local base = nil
|
||||
if Elevation.fieldFor(map.id) then
|
||||
base = {}
|
||||
for ty = y0, y1 do
|
||||
for tx = x0, x1 do
|
||||
Budget.tick()
|
||||
local k = keyOf(tx, ty)
|
||||
if tileAt[k] then
|
||||
local b = Elevation.baseAtTile(map, tx, ty)
|
||||
local s = shapeAt[k]
|
||||
if s and s.class == "ledge" then b = b - (s.h or 0) end
|
||||
if b ~= 0 then base[k] = b end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- buildings: whole sprites voxelized band by band ----
|
||||
--
|
||||
-- Before anything else looks at this grid. A profiled building is a
|
||||
@@ -255,7 +222,7 @@ 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, base = base,
|
||||
hideBareRing = hullRingOnly or nil,
|
||||
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
|
||||
Buildings.build(S, map, pixels(tileset), perRow)
|
||||
@@ -1035,7 +1002,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
ground = tpl.bg or false
|
||||
S.roundStamps[#S.roundStamps + 1] =
|
||||
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
|
||||
r = 16, my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
|
||||
r = 16 }
|
||||
end
|
||||
for dy = 0, 3 do
|
||||
for dx = 0, 3 do
|
||||
@@ -1068,8 +1035,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
end
|
||||
ground = tpl.bg or false
|
||||
S.roundStamps[#S.roundStamps + 1] =
|
||||
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8,
|
||||
my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
|
||||
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
|
||||
end
|
||||
-- headless (no pixels): no hull, but still claim the tiles so
|
||||
-- the volume path never boxes a pinned cell. Ground is the
|
||||
@@ -1169,8 +1135,6 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
|
||||
|
||||
local quads = S.objectQuads
|
||||
local wx0, wz0 = region.minX * 8, region.minY * 8
|
||||
-- a relief lies ON the terrain, so the whole slab rides the region's base
|
||||
local by = S.base and S.base[keyOf(region.minX, region.minY)] or 0
|
||||
for py = 0, bh - 1 do
|
||||
for px = 0, bw - 1 do
|
||||
if on(px, py) then
|
||||
@@ -1178,27 +1142,26 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
|
||||
local u = (srcU[i] + 0.5) / atlasW
|
||||
local v = (srcV[i] + 0.5) / atlasH
|
||||
local x, z = wx0 + px, wz0 + py
|
||||
local y0, y1 = by, by + h
|
||||
local function quad(c1, c2, c3, c4, shade)
|
||||
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
||||
end
|
||||
quad({ x, y1, z }, { x + 1, y1, z }, { x + 1, y1, z + 1 },
|
||||
{ x, y1, z + 1 }, RELIEF_SHADE.top)
|
||||
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
|
||||
{ x, h, z + 1 }, RELIEF_SHADE.top)
|
||||
if not on(px, py + 1) then
|
||||
quad({ x, y0, z + 1 }, { x + 1, y0, z + 1 }, { x + 1, y1, z + 1 },
|
||||
{ x, y1, z + 1 }, RELIEF_SHADE.south)
|
||||
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
|
||||
{ x, h, z + 1 }, RELIEF_SHADE.south)
|
||||
end
|
||||
if not on(px, py - 1) then
|
||||
quad({ x + 1, y0, z }, { x, y0, z }, { x, y1, z },
|
||||
{ x + 1, y1, z }, RELIEF_SHADE.north)
|
||||
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
|
||||
{ x + 1, h, z }, RELIEF_SHADE.north)
|
||||
end
|
||||
if not on(px - 1, py) then
|
||||
quad({ x, y0, z }, { x, y0, z + 1 }, { x, y1, z + 1 },
|
||||
{ x, y1, z }, RELIEF_SHADE.side)
|
||||
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
|
||||
{ x, h, z }, RELIEF_SHADE.side)
|
||||
end
|
||||
if not on(px + 1, py) then
|
||||
quad({ x + 1, y0, z + 1 }, { x + 1, y0, z }, { x + 1, y1, z },
|
||||
{ x + 1, y1, z + 1 }, RELIEF_SHADE.side)
|
||||
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
|
||||
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1245,11 +1208,10 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
|
||||
return ns ~= nil and ns.art == "bookcase"
|
||||
end
|
||||
|
||||
local by = S.base and S.base[keyOf(tx, frontTy)] or 0
|
||||
for band = 0, bands - 1 do
|
||||
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
|
||||
local u0, u1, v0, v1 = uvRect(tile)
|
||||
local y0, y1 = by + band * 8, by + band * 8 + 8
|
||||
local y0, y1 = band * 8, band * 8 + 8
|
||||
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
|
||||
{ x1, y1, z1 }, { x0, y1, z1 },
|
||||
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
||||
@@ -1368,7 +1330,6 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
local q0 = #quads
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
||||
local mx, mz = cx * 16, cy * 16
|
||||
@@ -1505,16 +1466,6 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the whole flight was built from y=0; a raised base lifts it after
|
||||
-- the fact so the geometry above stays in the cell's own space
|
||||
local by = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0
|
||||
if by ~= 0 then
|
||||
for i = q0 + 1, #quads do
|
||||
local q = quads[i]
|
||||
for c = 1, 4 do q[c][2] = q[c][2] + by end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
||||
@@ -2077,8 +2028,6 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
baseY, support = bs.h, bs
|
||||
end
|
||||
end
|
||||
-- and whatever it stands on, it stands on it at the terrain's base
|
||||
baseY = baseY + (S.base and S.base[keyOf(cluster.minX, cluster.maxY)] or 0)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
@@ -2304,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
-- 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.
|
||||
-- seat you merely walk up to is in a walkable one. The row under his
|
||||
-- card is SCANNED for the tallest authored upright rather than read at
|
||||
-- its west corner: the corner tile can be furniture that is not his
|
||||
-- seat (the couch's raised backrest column stands there, `top` art and
|
||||
-- taller than the cushion he actually sits on).
|
||||
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
|
||||
if blocked then
|
||||
for dx = 0, fig.w - 1 do
|
||||
local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
|
||||
if bs and bs.authored and bs.art == "upright"
|
||||
and (bs.h or 0) > baseY then
|
||||
baseY = bs.h
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
@@ -2342,7 +2299,7 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
quads = quads,
|
||||
wx = tx * 8 + minX,
|
||||
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
|
||||
y = baseY + (S.base and S.base[keyOf(tx, ty + fig.h - 1)] or 0),
|
||||
y = baseY,
|
||||
}
|
||||
|
||||
-- What each covered tile wears now that he is off it. Only the ART
|
||||
@@ -2487,13 +2444,12 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
|
||||
templates[tileId] = tpl
|
||||
end
|
||||
local wx, wz = tx * 8, ty * 8
|
||||
local wy = S.base and S.base[k] or 0
|
||||
for _, q in ipairs(tpl) do
|
||||
quads[#quads + 1] = {
|
||||
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
|
||||
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
}
|
||||
end
|
||||
@@ -2687,13 +2643,12 @@ function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
|
||||
templates[tileId] = tpl
|
||||
end
|
||||
local wx, wz = tx * 8, ty * 8
|
||||
local wy = S.base and S.base[k] or 0
|
||||
for _, q in ipairs(tpl) do
|
||||
quads[#quads + 1] = {
|
||||
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
|
||||
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = {
|
||||
bed = 7,
|
||||
stool = 8,
|
||||
counter = 8,
|
||||
-- the raised back band of low seating: the Center couch's west strip
|
||||
-- is drawn from above like the rest of the couch, but depicts the
|
||||
-- back and arm rising over the 8px seat
|
||||
backrest = 12,
|
||||
table = 12,
|
||||
desk = 24,
|
||||
prop = 16,
|
||||
@@ -145,6 +149,9 @@ local ART = {
|
||||
-- profile archetype Structures builds real steps for -- rising flights
|
||||
-- for stairs leading up, sunken stairwells for stairs leading down
|
||||
bed = "top",
|
||||
-- a backrest's art is the couch seen from above, so like the bed it
|
||||
-- rides the top face of its taller box
|
||||
backrest = "top",
|
||||
stool = "billboard",
|
||||
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
||||
-- so exactly the drawing's bottom row stands up as the front and
|
||||
@@ -295,6 +302,24 @@ function TileShape.forMap(map)
|
||||
if cache[id] then return cache[id] end
|
||||
|
||||
local heights = TileShape.heights()
|
||||
-- Per-tileset height overrides (a tileset entry's `heights`): the class
|
||||
-- vocabulary is global but the drawings are not -- the DOJO lab tables
|
||||
-- are drawn 6px tall where the default `table` is 12 -- and the height
|
||||
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
|
||||
-- actually stands, or the starter balls float over their own table.
|
||||
-- Same gate as the global list: known classes, numbers only.
|
||||
do
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[id]
|
||||
local over = entry and entry.heights
|
||||
if type(over) == "table" then
|
||||
for class, h in pairs(over) do
|
||||
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
|
||||
heights[class] = h
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local authored = authoredGroups(id, heights)
|
||||
local count = math.floor((tileset.imageWidth or 128) / 8)
|
||||
* math.floor((tileset.imageHeight or 48) / 8)
|
||||
|
||||
+3
-8
@@ -509,13 +509,8 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
local fov = 2 * math.atan(1 / (2 * focal))
|
||||
Voxel3D.fovY = fov
|
||||
|
||||
-- The height the orbit looks AT -- the smoothed ground under the
|
||||
-- player's feet (VoxelScene tracks it), so climbing a terrace does not
|
||||
-- slide the walker up the screen at a pitched camera. Zero on flat
|
||||
-- terrain, which is the framing this rig always had.
|
||||
local fy = Voxel3D.focusY or 0
|
||||
local focus = { cx, fy, cy }
|
||||
local eye = { cx, fy + dist * math.cos(a), cy + dist * math.sin(a) }
|
||||
local focus = { cx, 0, cy }
|
||||
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
|
||||
@@ -720,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
||||
if grid then
|
||||
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
|
||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
|
||||
end
|
||||
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
||||
-- every frame rather than once, because a scene that opened mid-ghost --
|
||||
|
||||
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
|
||||
-- 1.0 here is the one-pixel wireframe.
|
||||
VoxelGrid.WIDTH = 1.0
|
||||
|
||||
-- The same width in the CANVAS pixels the shader measures in, which is what
|
||||
-- every sender of it actually wants.
|
||||
--
|
||||
-- The two are the same number until AA renders the pass larger than the
|
||||
-- window (see AntiAlias): there a canvas pixel is a fraction of a display
|
||||
-- one, and a width left at 1.0 would come out a half or a quarter of a line
|
||||
-- after the fold -- the wireframe fading as the smoothing goes up, which
|
||||
-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and
|
||||
-- simply gains the antialiasing everything else in the frame just gained.
|
||||
function VoxelGrid.width()
|
||||
return VoxelGrid.WIDTH * V.require("AntiAlias").factor()
|
||||
end
|
||||
|
||||
-- where it persists and the rows that cycle it (see ModSetting)
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
+7
-32
@@ -24,15 +24,11 @@ local Sky = V.require("Sky")
|
||||
local Water = V.require("Water")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local Elevation = V.require("Elevation")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local VoxelScene = {}
|
||||
|
||||
-- the camera's smoothed focus height (see render); nil until first framed
|
||||
local focusHeight = nil
|
||||
|
||||
-- What the active display mode actually paints with.
|
||||
--
|
||||
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
|
||||
@@ -170,33 +166,24 @@ local YAW = {
|
||||
-- top of it rather than sunk into it. Uses the same bottom-left collision
|
||||
-- tile the engine walks on (Map:cellTile).
|
||||
local function groundAt(map, cellX, cellY)
|
||||
-- The terrain's base under the cell -- 0 wherever no elevation field
|
||||
-- exists (every interior), so the flat world keeps its old answers.
|
||||
local b = Elevation.baseAt(map, cellX, cellY)
|
||||
-- Off the map, cellTile border-extends into the map's borderBlock --
|
||||
-- which on maps ringed with trees is a RAISED tile. The only entity
|
||||
-- ever standing off-map is the player mid seam-step (placed one cell
|
||||
-- before the connection entry), and the ground actually rendered
|
||||
-- there is the departed neighbour's flat walkway: the border apron's
|
||||
-- own base (baseAt clamps to the nearest body cell, which is how the
|
||||
-- apron is meshed). Without this, crossing into such a map hoisted
|
||||
-- the walker tree-high for exactly one step -- the "hops like a
|
||||
-- ledge" seam bug.
|
||||
if not map:inBounds(cellX, cellY) then return b end
|
||||
-- there is the departed neighbour's flat walkway: height 0. Without
|
||||
-- this, crossing into such a map hoisted the walker tree-high for
|
||||
-- exactly one step -- the "hops like a ledge" seam bug.
|
||||
if not map:inBounds(cellX, cellY) then return 0 end
|
||||
local shapes = TileShape.forMap(map)
|
||||
local s = shapes[map:cellTile(cellX, cellY)]
|
||||
if not s then return b end
|
||||
if not s then return 0 end
|
||||
-- a recessed class (water) still supports whatever stands on it; only
|
||||
-- raised ground lifts the model. Stairs never do: the class height is
|
||||
-- the flight's TALL end, but the player enters at floor level and the
|
||||
-- warp fires as they step in -- lifting them onto the geometry read as
|
||||
-- climbing an invisible block
|
||||
if s.art == "stair" then return b end
|
||||
-- on solved terrain a ledge is the high plateau's rim, its lip flush
|
||||
-- with the ground it belongs to: the solver's base IS its top. On a
|
||||
-- flat map it is still the classic 6px bump you stand on top of.
|
||||
if s.class == "ledge" and Elevation.fieldFor(map.id) then return b end
|
||||
return b + (s.h > 0 and s.h or 0)
|
||||
if s.art == "stair" then return 0 end
|
||||
return s.h > 0 and s.h or 0
|
||||
end
|
||||
|
||||
VoxelScene.YAW = YAW
|
||||
@@ -789,18 +776,6 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
|
||||
water, nbWater)
|
||||
|
||||
-- The camera's focus height chases the ground under the player's feet,
|
||||
-- eased so a 2px terrace tread is a glide rather than a pop; a WARP-
|
||||
-- sized jump (raised route -> interior at 0) snaps instead of swooping
|
||||
-- the whole frame through the floor.
|
||||
local targetY = me and me.gh or 0
|
||||
if focusHeight == nil or math.abs(targetY - focusHeight) > 24 then
|
||||
focusHeight = targetY
|
||||
else
|
||||
focusHeight = focusHeight + (targetY - focusHeight) * 0.12
|
||||
end
|
||||
Voxel3D.focusY = focusHeight
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return nil
|
||||
end
|
||||
|
||||
+1
-1
@@ -1192,7 +1192,7 @@ function Water.begin(ctx)
|
||||
if ctx.grid then
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
send("gridDark", VoxelGrid.DARK)
|
||||
send("gridWidth", VoxelGrid.WIDTH)
|
||||
send("gridWidth", VoxelGrid.width())
|
||||
end
|
||||
|
||||
Water.sendSky(sh, ctx)
|
||||
|
||||
@@ -82,6 +82,7 @@ local BattleExit = V.require("BattleExit")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
|
||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||
-- calls this, and it is defined further down with the settings it drives.
|
||||
@@ -203,20 +204,34 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- a magnified low-res image, while the FX closures keep drawing in
|
||||
-- world-pixel units.
|
||||
local sw, sh = sceneSize(ctx)
|
||||
local canvas = VoxelScene.render(ctx.state, sw, sh,
|
||||
-- With AA on, the whole pass runs into a canvas BIGGER than the window
|
||||
-- and is folded back down at the end (see AntiAlias). Nothing between
|
||||
-- these two lines knows: every pass in the frame measures itself in the
|
||||
-- canvas it was handed, so the sky's dither, the water's march and the
|
||||
-- camera itself all come out the same picture at a higher sample rate.
|
||||
local rw, rh = AntiAlias.expand(sw, sh)
|
||||
local canvas = VoxelScene.render(ctx.state, rw, rh,
|
||||
ctx.vw, ctx.vh, ctx.paletteFor)
|
||||
if not canvas then return nil end -- fall back to the 2D path
|
||||
if Voxel3D.beginOverlay() then
|
||||
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
|
||||
-- they are drawing into the supersampled canvas alongside everything
|
||||
-- else -- so the scale goes up with it, or the "!" bubble lands the
|
||||
-- right place at half the size. project() already answers in canvas
|
||||
-- pixels, so only the scale needs saying.
|
||||
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
||||
ctx.scale)
|
||||
ctx.scale * AntiAlias.factor())
|
||||
Voxel3D.endOverlay()
|
||||
end
|
||||
return canvas
|
||||
-- and back to the window's own size, which is what the engine composites
|
||||
-- one canvas pixel to one display pixel. A pass-through when AA is off.
|
||||
return AntiAlias.resolve(canvas, sw, sh, "world")
|
||||
end,
|
||||
|
||||
invalidate = function()
|
||||
Voxel3D.invalidate()
|
||||
OverworldBattle.invalidate()
|
||||
AntiAlias.invalidate()
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
end,
|
||||
})
|
||||
@@ -361,6 +376,21 @@ local SETTINGS = {
|
||||
.. "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." },
|
||||
-- Marked `full` for the opposite reason the battle rows are: this is not a
|
||||
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
|
||||
-- the diorama, not a licence to spend four times the fill rate on the
|
||||
-- machine it happens to be running on, so it neither sets this nor takes
|
||||
-- the row away -- the player decides what their hardware can carry, from
|
||||
-- inside FULL like anywhere else.
|
||||
{ AntiAlias.setting,
|
||||
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
|
||||
.. "lips, a tree against the sky -- by rendering the diorama larger than "
|
||||
.. "the window and folding it back down. Every edge in the picture "
|
||||
.. "softens with them, the tileset's own texels included, so the diorama "
|
||||
.. "reads smoother rather than sharper. 2X costs half again as many "
|
||||
.. "pixels in each direction and 4X twice, which makes this the most "
|
||||
.. "expensive row in the mod.",
|
||||
full = true },
|
||||
}
|
||||
|
||||
local schema = {}
|
||||
@@ -700,23 +730,6 @@ mod.events:on("map.reloaded", function(payload)
|
||||
if mapId then ChunkMesher.invalidate(mapId) end
|
||||
end)
|
||||
|
||||
-- ------- the terrain solver needs the whole map registry
|
||||
--
|
||||
-- lib/Elevation.lua cuts the connected overworld into plateaus, which
|
||||
-- takes every map's blocks and connections at once -- not just the one
|
||||
-- being walked. The engine keeps that registry in main.lua's `Game`,
|
||||
-- which is a LOCAL there and reachable from no mod, so it arrives here
|
||||
-- instead: `mods.loaded` carries the merged dataset, and it is the only
|
||||
-- moment the whole of it is handed over. Without this the solver found
|
||||
-- no data, answered "no field" for every map, and the world stayed as
|
||||
-- flat as it ever was -- silently, which is the part that cost a while.
|
||||
mod.events:on("mods.loaded", function(payload)
|
||||
local data = payload and payload.data
|
||||
if data and data.maps then
|
||||
V.require("Elevation").install(data)
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------- rows come and go, so the menu has to notice
|
||||
--
|
||||
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
|
||||
@@ -877,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.4.0"
|
||||
mod.exports.version = "1.4.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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
-- Driver: one scene, once per rung of the AA row.
|
||||
--
|
||||
-- The AA row is the one setting in this mod whose whole effect is a pixel
|
||||
-- wide, so it is also the one that cannot be judged from a description. This
|
||||
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
|
||||
-- them side by side, magnified, and the row is either doing something or it
|
||||
-- is not.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
|
||||
-- SHOT_DIR=<dir> lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
|
||||
-- AA_MAP map id (default VIRIDIAN_CITY)
|
||||
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
|
||||
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
|
||||
--
|
||||
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
|
||||
-- diagonal of a fence and a tree's silhouette against the sky are the edges
|
||||
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
|
||||
-- angle to the pixel grid.
|
||||
--
|
||||
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
|
||||
-- three shots differ ONLY by the row under test, or comparing them means
|
||||
-- nothing. The clock is pinned, the animated tile slots are frozen, the
|
||||
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
|
||||
-- (a gaussian over the frame would smear away the very edges being looked at).
|
||||
--
|
||||
-- Nothing here writes the player's options: the row is moved with
|
||||
-- ModSetting:sync, which moves the cached index and persists nothing.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
|
||||
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
|
||||
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
|
||||
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
|
||||
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
|
||||
if sf == "" then sf = "up" end
|
||||
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
local NPC = require("src.world.NPC")
|
||||
if not NPC.dramaticShapeAaFreeze then
|
||||
local inner = NPC.update
|
||||
function NPC:update(...)
|
||||
self.frozen = true
|
||||
return inner(self, ...)
|
||||
end
|
||||
NPC.dramaticShapeAaFreeze = true
|
||||
end
|
||||
pcall(love.math.setRandomSeed, 20260801)
|
||||
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function cameraStill()
|
||||
local o = game.overworld
|
||||
local c = o and o.camera
|
||||
if not c then return true end
|
||||
local lx, ly, held = nil, nil, 0
|
||||
for _ = 1, 300 do
|
||||
if c.x == lx and c.y == ly then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
lx, ly = c.x, c.y
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The camera PITCH, which is the one that caught this driver out. The tween
|
||||
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
|
||||
-- angle stops moving, so the first shot of a run came out at 67 degrees
|
||||
-- while the two after it were at 75 -- three frames that differ by the
|
||||
-- camera, in a comparison whose entire subject is a pixel.
|
||||
local function angleStill()
|
||||
local last, held = nil, 0
|
||||
for _ = 1, 600 do
|
||||
if Voxel.angle == last then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
last = Voxel.angle
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
angleStill()
|
||||
cameraStill()
|
||||
-- the sun map is only redrawn when its inputs move, and the AA row is not
|
||||
-- one of them -- so force one pass at the settled camera rather than
|
||||
-- comparing a frame against a map fitted a few hundredths of a pixel ago
|
||||
if ShadowMap.forget then ShadowMap.forget() end
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
DayNight.setting:sync("day")
|
||||
U.teleport(game, MAP, sx, sy, sf)
|
||||
Pipelines.setLevel("voxel", RUNG)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
|
||||
-- Warm up before the FIRST shot, not just between them.
|
||||
--
|
||||
-- Neighbour maps are requested from inside the render itself
|
||||
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
|
||||
-- means "nothing has been asked for yet", not "everything is here". The
|
||||
-- first capture of a run came out with the map beyond Viridian missing --
|
||||
-- a whole tree line absent from one frame of a three-way comparison, which
|
||||
-- looks exactly like the row under test doing something enormous. Settling
|
||||
-- twice lets the first render request the neighbourhood and the second
|
||||
-- drain it.
|
||||
settle()
|
||||
settle()
|
||||
|
||||
local shots, missed = 0, 0
|
||||
for _, samples in ipairs({ 0, 2, 4 }) do
|
||||
AntiAlias.setting:sync(samples)
|
||||
settle()
|
||||
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
|
||||
-- run disagree by more than the row could account for, this is what says
|
||||
-- which input moved -- it is how the camera-tween and the neighbour-mesh
|
||||
-- settles above were both found.
|
||||
if os.getenv("AA_TRACE") == "1" then
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local o = game.overworld
|
||||
local cw, chh = Voxel3D.size()
|
||||
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
|
||||
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
|
||||
Voxel3D.cell or -1, cw or 0, chh or 0,
|
||||
o and o.camera and o.camera.x or -1,
|
||||
o and o.camera and o.camera.y or -1,
|
||||
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
|
||||
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
|
||||
end
|
||||
local path = ("%s/aa_%d.png"):format(ROOT, samples)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
print(("[aa] %s samples=%d"):format(path, samples))
|
||||
else
|
||||
missed = missed + 1
|
||||
print("[aa] capture did not reach disk: " .. path)
|
||||
end
|
||||
end
|
||||
-- left where it was found, so a run cannot leak a rung into the next one
|
||||
AntiAlias.setting:sync(0)
|
||||
|
||||
print(("[aa] %d shots into %s (%d failed to reach disk)")
|
||||
:format(shots, ROOT, missed))
|
||||
end
|
||||
+199
-11
@@ -145,6 +145,9 @@ T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
|
||||
-- back sprite (or no staged fights at all) can still say so from inside FULL
|
||||
T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
|
||||
T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
|
||||
-- and AA, for the opposite reason: it is not a knob on the look at all, it is
|
||||
-- what the look COSTS, and only the player knows what their machine can carry
|
||||
T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
|
||||
|
||||
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
|
||||
-- be reached while FULL owns it, so a value changed underneath (the mod
|
||||
@@ -379,9 +382,11 @@ end
|
||||
Pipelines.setLevel("voxel", 2)
|
||||
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
{ data = Data }, { { id = "text_speed" } })
|
||||
T.eq(#hookedRows, 7, "the options hook added a row per setting")
|
||||
T.eq(#hookedRows, 8, "the options hook added a row per setting")
|
||||
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
|
||||
local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
|
||||
-- the AA row is hookedRows[8]; it is read in its own block below, because
|
||||
-- this chunk is one main function and has 200 local slots to spend
|
||||
T.eq(water.label, "WATER", "the water row carries its label")
|
||||
T.eq(water.value(), "FULL",
|
||||
"and defaults to FULL -- reflections are the point of having the row")
|
||||
@@ -459,6 +464,57 @@ curve.step(settingGame, 1)
|
||||
curve.step(settingGame, 1)
|
||||
T.eq(curve.value(), "OFF", "the curve is left off for the rows below")
|
||||
|
||||
-- ------- AA renders the pass larger and folds it back down
|
||||
--
|
||||
-- The ladder is SAMPLES per display pixel, so the canvas scale each rung asks
|
||||
-- for is its square root -- and the scale in force has to be readable after
|
||||
-- the fact, because two things are quoted in DISPLAY pixels and have to be
|
||||
-- multiplied up into the canvas the pass actually opened: the wireframe's
|
||||
-- line width and the FX overlay's sprite scale.
|
||||
do
|
||||
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
|
||||
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
|
||||
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
|
||||
local aa = hookedRows[8]
|
||||
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
|
||||
T.eq(aa.value(), "OFF",
|
||||
"and starts off -- supersampling is a cost knob, and a mod must not spend "
|
||||
.. "four times the fill rate of the machine it lands on unasked")
|
||||
|
||||
T.eq(AntiAlias.samples(), 0, "AA is off by default")
|
||||
local w, h = AntiAlias.expand(320, 200)
|
||||
T.eq(w, 320, "an OFF row renders at the window's own width")
|
||||
T.eq(h, 200, "and its height")
|
||||
T.eq(AntiAlias.factor(), 1, "with nothing to multiply display pixels by")
|
||||
T.eq(VoxelGrid.width(), VoxelGrid.WIDTH,
|
||||
"so the wireframe is the one-display-pixel line it has always been")
|
||||
|
||||
aa.step(aaGame, 1)
|
||||
T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel")
|
||||
T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2,
|
||||
"the sample count persists beside the other settings, not over them")
|
||||
w, h = AntiAlias.expand(320, 200)
|
||||
T.eq(w, 453, "two samples a pixel is a canvas root-two wider")
|
||||
T.eq(h, 283, "and root-two taller")
|
||||
T.check(math.abs(AntiAlias.factor() - 453 / 320) < 1e-9,
|
||||
"and the factor is what it MEASURED, not what the row asked for")
|
||||
|
||||
aa.step(aaGame, 1)
|
||||
T.eq(aa.value(), "4X", "and again to four")
|
||||
w, h = AntiAlias.expand(320, 200)
|
||||
T.eq(w, 640, "four samples a pixel is a canvas exactly twice the size")
|
||||
T.eq(h, 400, "in each direction, which is the 2x2 box the fold reads")
|
||||
T.eq(AntiAlias.factor(), 2, "with everything in display pixels doubled")
|
||||
T.eq(VoxelGrid.width(), VoxelGrid.WIDTH * 2,
|
||||
"the wireframe among them -- a seam left at 1.0 would fold down to half a "
|
||||
.. "line, so the smoothing row would appear to fade the grid row out")
|
||||
|
||||
aa.step(aaGame, 1)
|
||||
T.eq(aa.value(), "OFF", "and the ladder wraps back to off")
|
||||
AntiAlias.expand(320, 200)
|
||||
T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass")
|
||||
end
|
||||
|
||||
-- ------- the animated terrain atlas survives an engine without its seams
|
||||
--
|
||||
-- Regression: cycling palette modes with voxel mode on eventually killed
|
||||
@@ -2476,7 +2532,35 @@ T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
|
||||
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
|
||||
"directly under the row it belongs to")
|
||||
|
||||
-- ------- and which pic is the pinned one is asked with the other side BLANKED
|
||||
--
|
||||
-- picImage asks this so BattlePics knows whether a pic's feet are on the text
|
||||
-- box -- where its bottom edge seals, and its belly stops being see-through --
|
||||
-- and it is asked DURING the billboard render, inside which sideTexture has
|
||||
-- switched the side it is not drawing off by setting the field to FALSE rather
|
||||
-- than to nil (see OFF).
|
||||
--
|
||||
-- So the read has to be by truthiness. A test against nil passes that `false`
|
||||
-- through to the index below it, the error comes back out of sideTexture into
|
||||
-- the pcall that calls it, textures() reports no card for the side -- and the
|
||||
-- foe is simply not on the field. Which is the whole bug: fixing the player's
|
||||
-- back pic took the enemy's billboard out.
|
||||
local mine, theirs = {}, {}
|
||||
local live = { player = { sprite = mine }, enemy = { sprite = theirs } }
|
||||
T.eq(Battles.pinnedPic(live, mine), true,
|
||||
"the player's own mon is the pic on the box")
|
||||
T.eq(Battles.pinnedPic(live, theirs), false,
|
||||
"and the foe is geometry out on the map, whatever the mode")
|
||||
T.eq(Battles.pinnedPic({ player = false, enemy = { sprite = theirs } }, theirs),
|
||||
false, "asking about the foe while the player is blanked answers, not throws")
|
||||
T.eq(Battles.pinnedPic({ playerBackPic = mine }, mine), true,
|
||||
"the trainer back holds the slot until Go!, on the box like the mon")
|
||||
T.eq(Battles.pinnedPic({ player = false, playerBackPic = false }, mine), false,
|
||||
"and with the side blanked outright nothing of it is pinned")
|
||||
|
||||
Battles.backSetting:setIndex(1, backGame) -- and off for the rows below
|
||||
T.eq(Battles.pinnedPic(live, mine), false,
|
||||
"with BACK SPRITES off the player's mon is out on the map with the foe")
|
||||
end
|
||||
|
||||
-- ------- the hour reaches the FLAT world too
|
||||
@@ -2611,21 +2695,35 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
|
||||
-- reader over what came out. The pic is faked at the readback seam, which is
|
||||
-- the only thing between this and the pixels the engine would have blitted.
|
||||
local lastCanvas = nil -- what the readback asked newCanvas for
|
||||
local function fill(rows)
|
||||
-- '#' is ink and '.' the keyed-out nothing. 'W' is ink too, of the pic's
|
||||
-- LIGHTEST shade -- a highlight the decoder happened not to key -- which is
|
||||
-- what the paper a hole gets filled with is read off.
|
||||
local SHADE = { ["#"] = 0.25, ["W"] = 0.75 }
|
||||
-- reuse hands the SAME pic back through, which is how the two bottom rules
|
||||
-- can be asked of one image the way a running battle would ask them
|
||||
local function fill(rows, sealBottom, reuse)
|
||||
local W, H = #rows[1], #rows
|
||||
local built = nil
|
||||
local function fakeData()
|
||||
local px = {}
|
||||
local px, sh = {}, {}
|
||||
for y = 0, H - 1 do
|
||||
for x = 0, W - 1 do
|
||||
px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
|
||||
local shade = SHADE[rows[y + 1]:sub(x + 1, x + 1)]
|
||||
px[y * W + x] = shade and 1 or 0
|
||||
sh[y * W + x] = shade or 0
|
||||
end
|
||||
end
|
||||
return {
|
||||
px = px,
|
||||
px = px, sh = sh,
|
||||
getDimensions = function() return W, H end,
|
||||
getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end,
|
||||
setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
|
||||
getPixel = function(self, x, y)
|
||||
local k = y * W + x
|
||||
return self.sh[k], self.sh[k], self.sh[k], self.px[k]
|
||||
end,
|
||||
setPixel = function(self, x, y, r, g, b, a)
|
||||
self.px[y * W + x] = a
|
||||
self.sh[y * W + x] = r
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -2639,12 +2737,14 @@ local function fill(rows)
|
||||
built = data
|
||||
return { setFilter = function() end }
|
||||
end
|
||||
local pic = { getDimensions = function() return W, H end }
|
||||
local out = BattlePics.filled(pic)
|
||||
local pic = reuse or { getDimensions = function() return W, H end }
|
||||
local out = BattlePics.filled(pic, sealBottom)
|
||||
love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
|
||||
-- deliberately NOT invalidated: each figure brings its own pic, and the
|
||||
-- cache check at the bottom needs one of them still in there
|
||||
return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end
|
||||
return out, pic,
|
||||
built and function(x, y) return built.px[y * W + x] > 0.5 end,
|
||||
built and function(x, y) return built.sh[y * W + x] end
|
||||
end
|
||||
|
||||
-- ------- the cut at the feet, which is what the closed bottom edge is for
|
||||
@@ -2727,6 +2827,75 @@ local drainOut, drainPic, drain = fill({
|
||||
T.check(drainOut ~= drainPic and drain and drain(8, 4),
|
||||
"a narrow one is where the drawing ran out, and is paper")
|
||||
|
||||
-- ------- but a pic ON THE MENU has no mouth at all
|
||||
--
|
||||
-- The drain/mouth cut is for a pic standing on the MAP, where a wide opening
|
||||
-- along the bottom is a stride with real ground behind it. Under BACK SPRITES
|
||||
-- the player's mon is drawn in the GB's own slot with its feet flush on the
|
||||
-- text box, and the only thing under its lowest row is white box -- so nothing
|
||||
-- reaches it from below, whatever the opening's width, and the rule stops
|
||||
-- being a heuristic: paper is whatever the background cannot walk to from the
|
||||
-- left, the right or the top.
|
||||
--
|
||||
-- Which is the difference between a Pikachu and a wireframe. The pale-bodied
|
||||
-- back pics -- Pikachu, Seel, Dewgong, Chansey, Jigglypuff -- are drawn as
|
||||
-- OUTLINES, every shade-0 pixel inside the ink keyed away, and each of them
|
||||
-- leaks out through a bottom opening far too wide to read as a drain. On the
|
||||
-- map that reading is right; on the box it left the mon a rim with the arena
|
||||
-- showing through it.
|
||||
--
|
||||
-- The same stride figure the map rule leaves open, now standing on the box.
|
||||
local boxOut, boxPic, boxOpaque = fill({
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..###........###..",
|
||||
"..###........###..",
|
||||
"..###........###..",
|
||||
}, true)
|
||||
T.check(boxOut ~= boxPic and boxOpaque,
|
||||
"the gap a stride would have shown the world through is paper on the box")
|
||||
T.check(boxOpaque(8, 3) and boxOpaque(8, 5),
|
||||
"and it fills right down to the row the feet are on")
|
||||
|
||||
-- the seal is the BOTTOM alone: the sides and the top still let the background
|
||||
-- in, which is what keeps the silhouette cutting against the arena instead of
|
||||
-- standing the mon in a white block
|
||||
local boxGapOut, boxGapPic = fill({
|
||||
"..#####.",
|
||||
"..#...#.",
|
||||
"..#...#.",
|
||||
"....###.", -- opens at the left, and drains out that way
|
||||
"..#####.",
|
||||
"..#####.",
|
||||
}, true)
|
||||
T.eq(boxGapOut, boxGapPic,
|
||||
"a pocket that drains out to the side is background on the box too")
|
||||
|
||||
-- ------- and a hole is filled with the pic's OWN paper, not with white
|
||||
--
|
||||
-- Shade 0 is white only while the pic is still grays, and by the time one
|
||||
-- reaches here it usually is not: picImage hands it over after the bake -- a
|
||||
-- species SGB colour, a BGP fade mid-animation, PAL_BLACK across the whole
|
||||
-- screen while the blackout text is up -- and shade 0 travels with the rest.
|
||||
-- A hardcoded white belly would be the one lit thing on a blacked-out mon.
|
||||
--
|
||||
-- So the paper is read off the pic: the lightest shade still standing in it,
|
||||
-- which is shade 0 wherever the decoder could not reach one. It never has to
|
||||
-- guess -- all 151 of this game's back pics keep at least one, an eye or a
|
||||
-- highlight down a cheek.
|
||||
local _, _, _, paperShade = fill({
|
||||
"..####..",
|
||||
"..#WW#..", -- a highlight the decoder did not key: this is the paper
|
||||
"..#..#..",
|
||||
"..#..#..",
|
||||
"..####..",
|
||||
})
|
||||
T.eq(paperShade(3, 2), paperShade(3, 1),
|
||||
"the hole takes the lightest shade the pic still has")
|
||||
T.check(paperShade(3, 2) ~= paperShade(2, 2),
|
||||
"which is not the ink beside it")
|
||||
|
||||
-- ------- and the readback is measured in PIXELS, which is what kept the mons
|
||||
-- the size of the squares they stand on
|
||||
--
|
||||
@@ -2743,12 +2912,31 @@ T.check(drainOut ~= drainPic and drain and drain(8, 4),
|
||||
-- scale that was wrong everywhere.
|
||||
T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1,
|
||||
"the readback canvas is one texel per pic pixel, on a highdpi phone too")
|
||||
T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels")
|
||||
T.eq(lastCanvas.w, 8, "and it is the size of the pic, in those pixels")
|
||||
|
||||
-- the answer is cached on the image, so a pic costs one readback a session
|
||||
-- rather than one a frame -- checked on the first figure, which is still in
|
||||
-- there because fill() does not clear it
|
||||
T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original")
|
||||
-- and cached PER BOTTOM RULE, because one image answers differently on the map
|
||||
-- and on the box. A single table for both would hand whichever caller asked
|
||||
-- second the other one's answer -- the map's stencil to the menu, or a menu
|
||||
-- fill to a mon standing on grass -- which is this section's bug arriving
|
||||
-- through the cache rather than through the flood.
|
||||
--
|
||||
-- The stride figure again, on the SAME pic the map rule already answered for.
|
||||
local reOut = fill({
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..###........###..",
|
||||
"..###........###..",
|
||||
"..###........###..",
|
||||
}, true, stridePic)
|
||||
T.check(reOut ~= stridePic,
|
||||
"the pic the map left open still fills when the box asks for it")
|
||||
T.eq(BattlePics.filled(stridePic), stridePic,
|
||||
"and the map's own answer for it is still the pic itself")
|
||||
BattlePics.invalidate()
|
||||
end
|
||||
|
||||
|
||||
+304
-11
@@ -547,6 +547,98 @@ TEMPLATES = {
|
||||
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
|
||||
slab=4, front_eave=4, ledge=None, tileset="forest",
|
||||
),
|
||||
# F01: the starter-ball table in Oak's lab -- the first FURNITURE
|
||||
# through the pipeline, and the first drawing whose plot is smaller
|
||||
# than its grid. Rows 0-15 are the tabletop seen from above; 16-18
|
||||
# are the top slab's own front edge (black/#555/black -- exactly
|
||||
# what the rim treatment paints, so slab=3 and those rows fold into
|
||||
# the roof band instead of extruding); 19-21 are the base: corner
|
||||
# feet and the inset dark panel between them. The legs stand on
|
||||
# open FLOOR, so the measured ground line lands two rows short of
|
||||
# the grid; `depth` keeps the plot to the blocked cell row -- the
|
||||
# legs row of the grid is the walkable cell the player faces the
|
||||
# table from, and D = len(tiles) would stand the model in their
|
||||
# path. 16 top rows onto a 16px plot map 1:1: no cycling, and
|
||||
# roof_cycle is unreachable behind roof_back=16.
|
||||
"lab_table": dict(
|
||||
tiles=[
|
||||
[41, 59, 59, 59, 59, 42],
|
||||
[78, 57, 57, 57, 57, 79],
|
||||
[88, 89, 89, 89, 89, 90],
|
||||
],
|
||||
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
|
||||
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||
),
|
||||
# F02: the computer desk in Oak's lab -- and the Hall of Fame's
|
||||
# recording machine, the same drawing on the GYM atlas (one
|
||||
# placement each; both registered in voxel_heights). The one
|
||||
# DESK-SET template: the methodology's region classification at
|
||||
# part granularity. The desk is the sibling lab table (fascia rows
|
||||
# 16-18, base 19-21); on it stand a monitor over its keyboard
|
||||
# (left), a computer tower over a keyboard and mouse (middle), and
|
||||
# a sheet of paper LYING FLAT (right). Upright parts anchor their
|
||||
# drawn bottom row to the desk's top plane and wear their own drawn
|
||||
# tops as lids; flat parts lie one voxel proud with drawn row =
|
||||
# depth row -- the same 1:1 the tabletop itself is drawn with, so
|
||||
# an object's height ON the drawing is its position ON the desk.
|
||||
# The desk's own top is the one synthesized surface (the objects
|
||||
# cover every pixel of it), continued from the sibling tables'
|
||||
# pattern in the drawing's own shades.
|
||||
"lab_computers": dict(
|
||||
tiles=[
|
||||
[91, 92, 93, 94],
|
||||
[54, 55, 85, 95],
|
||||
[88, 89, 89, 90],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||
desk=dict(fascia=(16, 18), base=(19, 21)),
|
||||
parts=[
|
||||
dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10),
|
||||
depth=4), # the monitor
|
||||
dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard
|
||||
dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10),
|
||||
depth=6), # the tower
|
||||
dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse
|
||||
dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper
|
||||
],
|
||||
),
|
||||
# F03: the empty north table beside it -- the starter table's band
|
||||
# table verbatim on a grid two tiles narrower (one placement).
|
||||
"lab_table_small": dict(
|
||||
tiles=[
|
||||
[41, 59, 59, 42],
|
||||
[78, 57, 57, 79],
|
||||
[88, 89, 89, 90],
|
||||
],
|
||||
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
|
||||
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||
),
|
||||
# F04: the Pokemon Center's PC -- every Center's northeast corner
|
||||
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
|
||||
# shares this atlas. The lab desk-set read again: a
|
||||
# Mac-style unit drawn face-on -- white top band (rows 0-3), bezel,
|
||||
# screen and drive slot (4-14) -- standing at the back of a low
|
||||
# desk whose front face is rows 20-23 and whose drawn top (the
|
||||
# white sliver of row 15 and the margins beside the unit) names the
|
||||
# lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top
|
||||
# span, so the flat part carries an authored z origin: it lies at
|
||||
# the desk's front edge, in front of the unit.
|
||||
"center_pc": dict(
|
||||
tiles=[
|
||||
[66, 70],
|
||||
[82, 86],
|
||||
[9, 88],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2,
|
||||
desk=dict(fascia=(20, 21), base=(22, 23), lid="white"),
|
||||
parts=[
|
||||
dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14),
|
||||
depth=6), # the unit
|
||||
dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard
|
||||
],
|
||||
),
|
||||
# B23: the Victory Road entrance on Route 23: a rock face with two
|
||||
# barred doors. The roof band is the pale cliff top seen from above.
|
||||
"victory_road_gate": dict(
|
||||
@@ -642,7 +734,14 @@ def profile(sp, t):
|
||||
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
|
||||
top.append(min(r, t["roof_rows"]))
|
||||
|
||||
wall_h = H - t["roof_rows"]
|
||||
# The drawing's own ground line: the row after the last drawn one. A
|
||||
# building ends on the black threshold row it stands on (ground == H),
|
||||
# but furniture is drawn standing on open floor -- the lab table's
|
||||
# legs stop two rows short of its grid -- and extruding against H
|
||||
# would float it that far above its own plot.
|
||||
ground = max((y for y in range(H) for x in range(W) if inside(x, y)),
|
||||
default=H - 1) + 1
|
||||
wall_h = ground - t["roof_rows"]
|
||||
ytop = wall_h - 1 + t["slab"]
|
||||
|
||||
# Recesses: the panes the art seals behind a black frame. Non-black
|
||||
@@ -674,15 +773,136 @@ def profile(sp, t):
|
||||
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
|
||||
recess.update(cells)
|
||||
|
||||
# Depth is the PLOT. For a whole-drawing building that is the grid
|
||||
# itself; `depth` (in tile rows) names it when the grid runs past the
|
||||
# plot onto ground the drawing merely stands its legs on.
|
||||
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
|
||||
inside=inside, D=H, W=W, H=H)
|
||||
inside=inside, D=t.get("depth", len(t["tiles"])) * 8,
|
||||
ground=ground, W=W, H=H)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- stage 3 --
|
||||
def build_desk_set(sp, pr, t):
|
||||
"""A desk with separately-classified objects on it (the `parts` list):
|
||||
upright parts stand on the desk's top plane wearing their own drawn
|
||||
tops as lids, flat parts lie one voxel proud at drawn row = depth row,
|
||||
and the desk itself is the lab-table slab + base with a synthesized
|
||||
lid (the objects cover every drawn pixel of the tabletop)."""
|
||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||
inside = pr["inside"]
|
||||
col, src = sp["col"], sp["src"]
|
||||
ground = pr["ground"]
|
||||
vox = {}
|
||||
|
||||
def put(x, y, z, sx, sy):
|
||||
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
|
||||
|
||||
shade_px = {}
|
||||
for sy in range(H):
|
||||
for sx in range(W):
|
||||
if inside(sx, sy):
|
||||
shade_px.setdefault(col[sy][sx], (sx, sy))
|
||||
|
||||
def interior(sx, sy, lo, hi):
|
||||
if col[sy][sx] != BLACK:
|
||||
return sx
|
||||
step = 1 if sx < (lo + hi) // 2 else -1
|
||||
for d in range(1, 4):
|
||||
nx = sx + step * d
|
||||
if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK:
|
||||
return nx
|
||||
return sx
|
||||
|
||||
f0, f1 = t["desk"]["fascia"]
|
||||
b0, b1 = t["desk"]["base"]
|
||||
plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane
|
||||
|
||||
# the base band, extruded exactly like every lab table's
|
||||
for sy in range(b0, b1 + 1):
|
||||
y = ground - 1 - sy
|
||||
for sx in range(W):
|
||||
if not inside(sx, sy):
|
||||
continue
|
||||
ix = interior(sx, sy, 0, W - 1)
|
||||
for z in range(D):
|
||||
put(sx, y, z, sx if z in (0, D - 1) else ix, sy)
|
||||
for sx, sy in pr["recess"]:
|
||||
if b0 <= sy <= b1:
|
||||
vox.pop((sx, ground - 1 - sy, D - 1), None)
|
||||
|
||||
# the slab: the fascia rows wrap every side; the lid is the one
|
||||
# synthesized surface in the model -- the drawing never paints the
|
||||
# tabletop (its objects cover it), so the lid continues the sibling
|
||||
# tables' pattern in the drawing's own shades: black rim, white
|
||||
# highlight courses along the north and west, grey field
|
||||
for i, sy in enumerate(range(f0, f1 + 1)):
|
||||
y = plane - 1 - i
|
||||
for sx in range(W):
|
||||
for z in range(D):
|
||||
put(sx, y, z, sx, sy)
|
||||
field = WHITE if t["desk"].get("lid") == "white" else GREY
|
||||
for sx in range(W):
|
||||
for z in range(D):
|
||||
if sx in (0, W - 1) or z in (0, D - 1):
|
||||
shade = BLACK
|
||||
elif sx == 1 or z == 1:
|
||||
shade = WHITE
|
||||
else:
|
||||
shade = field
|
||||
px = shade_px.get(shade) or shade_px[BLACK]
|
||||
put(sx, plane - 1, z, px[0], px[1])
|
||||
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
for sy in range(r0, r1 + 1):
|
||||
# drawn row = depth row by default; `z` renames the
|
||||
# origin when the flat sits below the desk's own drawn
|
||||
# top span (the Center PC's keyboard)
|
||||
z = p.get("z", r0) + (sy - r0)
|
||||
if not 0 <= z < D:
|
||||
continue
|
||||
for sx in range(x0, x1 + 1):
|
||||
if inside(sx, sy):
|
||||
put(sx, plane, z, sx, sy)
|
||||
continue
|
||||
tr0, tr1 = p["top"]
|
||||
fr0, fr1 = p["facade"]
|
||||
pd = p["depth"]
|
||||
ytp = plane + (fr1 - fr0)
|
||||
for sx in range(x0, x1 + 1):
|
||||
# the lid: the part's drawn top laid across its depth from
|
||||
# the back, last row continuing forward; the front lid row
|
||||
# is the facade's own top row -- the drawn front-top edge
|
||||
for z in range(pd):
|
||||
sy = fr0 if z == pd - 1 else min(tr0 + z, tr1)
|
||||
while sy <= tr1 and not inside(sx, sy):
|
||||
sy += 1
|
||||
if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)):
|
||||
continue
|
||||
put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy)
|
||||
# the body: facade rows anchored to the desk's top plane
|
||||
for sy in range(fr0 + 1, fr1 + 1):
|
||||
y = plane + (fr1 - sy)
|
||||
if not inside(sx, sy):
|
||||
continue
|
||||
ix = interior(sx, sy, x0, x1)
|
||||
for z in range(pd):
|
||||
if z == pd - 1:
|
||||
if (sx, sy) not in pr["recess"]:
|
||||
put(sx, y, z, sx, sy)
|
||||
else:
|
||||
put(sx, y, z, sx if z == 0 else ix, sy)
|
||||
return vox
|
||||
|
||||
|
||||
def build(sp, pr, t):
|
||||
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
|
||||
the roof solid overwrites what it intersects and the walls are trimmed
|
||||
to the roof's underside."""
|
||||
if t.get("parts"):
|
||||
return build_desk_set(sp, pr, t)
|
||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
|
||||
col, src = sp["col"], sp["src"]
|
||||
@@ -720,7 +940,9 @@ def build(sp, pr, t):
|
||||
|
||||
# ---- walls: the facade rows extruded straight back, trimmed under the roof
|
||||
for sy in range(t["roof_rows"], H):
|
||||
y = H - 1 - sy
|
||||
y = pr["ground"] - 1 - sy # rows below the ground line are floor
|
||||
if y < 0:
|
||||
continue
|
||||
for sx in range(W):
|
||||
if not inside(sx, sy) or trimmed(sx, y):
|
||||
continue
|
||||
@@ -742,7 +964,7 @@ def build(sp, pr, t):
|
||||
if t["ledge"]:
|
||||
l0, l1 = t["ledge"]
|
||||
for sy in range(l0, l1 + 1):
|
||||
y = H - 1 - sy
|
||||
y = pr["ground"] - 1 - sy
|
||||
for sx in range(W):
|
||||
if inside(sx, sy) and not trimmed(sx, y):
|
||||
for z in (-2, -1, D, D + 1):
|
||||
@@ -750,7 +972,7 @@ def build(sp, pr, t):
|
||||
|
||||
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
|
||||
for sx, sy in pr["recess"]:
|
||||
vox.pop((sx, H - 1 - sy, D - 1), None)
|
||||
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
|
||||
|
||||
# ---- roof: flat top over the plateau, stepped diagonal ends
|
||||
z0, z1 = 0, D - 1 + t["front_eave"]
|
||||
@@ -805,6 +1027,83 @@ def verify(vox, sp, pr, t):
|
||||
for (x, y, z), _ in vox.items():
|
||||
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
|
||||
|
||||
# A desk set answers its own asserts; a facade-only template (roof_rows
|
||||
# == 0) has no roof band, so there is no surface to hold level or slope
|
||||
# and no coverage to demand of it; everything with a roof band answers
|
||||
# the full set.
|
||||
if t.get("parts"):
|
||||
verify_desk_set(vox, pr, t)
|
||||
elif t["roof_rows"] == 0:
|
||||
# its one geometric intent: a straight extrusion, so each column
|
||||
# of the drawing is solid from its lowest voxel to its top. NOT
|
||||
# from the ground -- the drawing's base band is inset like every
|
||||
# lab table's, and the body's outer columns legitimately
|
||||
# overhang it -- and not on the recess layer, which sinks panes.
|
||||
lo, hi = {}, {}
|
||||
for (x, y, z) in vox:
|
||||
lo[(x, z)] = min(lo.get((x, z), y), y)
|
||||
hi[(x, z)] = max(hi.get((x, z), y), y)
|
||||
for (x, z), h in hi.items():
|
||||
if z == D - 1:
|
||||
continue
|
||||
assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \
|
||||
f"facade column has a hole at {x},{z}"
|
||||
else:
|
||||
verify_roof(vox, pr, t)
|
||||
|
||||
shell = [k for k in vox if not all(
|
||||
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
|
||||
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
||||
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
|
||||
return shell
|
||||
|
||||
|
||||
def verify_desk_set(vox, pr, t):
|
||||
W, D = pr["W"], pr["D"]
|
||||
f0, f1 = t["desk"]["fascia"]
|
||||
b0, b1 = t["desk"]["base"]
|
||||
plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
||||
|
||||
# the slab is a full solid under everything on it
|
||||
for x in range(W):
|
||||
for z in range(D):
|
||||
for y in range(b1 - b0 + 1, plane):
|
||||
assert (x, y, z) in vox, f"slab hole at {x},{y},{z}"
|
||||
|
||||
# each part stands where authored -- upright bodies solid from the
|
||||
# desk to their own drawn top (bar the front layer, whose panes
|
||||
# sink) -- and nothing stands anywhere else
|
||||
tops = {}
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
z0 = p.get("z", r0)
|
||||
for x in range(x0, x1 + 1):
|
||||
for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1):
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), plane)
|
||||
else:
|
||||
fr0, fr1 = p["facade"]
|
||||
ytp = plane + (fr1 - fr0)
|
||||
for x in range(x0, x1 + 1):
|
||||
for z in range(p["depth"]):
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), ytp)
|
||||
if z == p["depth"] - 1:
|
||||
continue
|
||||
ys = [y for y in range(plane, ytp + 1)
|
||||
if (x, y, z) in vox]
|
||||
assert ys == list(range(ys[0], ys[0] + len(ys))) \
|
||||
if ys else True, f"part column hole at {x},{z}"
|
||||
for (x, y, z) in vox:
|
||||
assert y <= tops.get((x, z), plane - 1), \
|
||||
f"voxel above its part at {x},{y},{z}"
|
||||
|
||||
|
||||
def verify_roof(vox, pr, t):
|
||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||
ytop, top, slab = pr["ytop"], pr["top"], t["slab"]
|
||||
T = lambda x: ytop - top[max(0, min(W - 1, x))]
|
||||
|
||||
# The roof surface reads over the columns the drawing actually paints:
|
||||
# a sprite inset from its box says nothing about the rest.
|
||||
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
|
||||
@@ -845,12 +1144,6 @@ def verify(vox, sp, pr, t):
|
||||
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
|
||||
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
|
||||
|
||||
shell = [k for k in vox if not all(
|
||||
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
|
||||
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
||||
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
|
||||
return shell
|
||||
|
||||
|
||||
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
|
||||
# Mirroring depth swings the camera round to the other side. Mirror
|
||||
|
||||
Reference in New Issue
Block a user