mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 16:00:53 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d37cd8e6 | |||
| 3f7210bfcd | |||
| 9ef8644bff | |||
| e6d4059c38 | |||
| 1a283d6771 | |||
| a7c9541ac4 | |||
| 91cc2d6f51 | |||
| c82598b24c | |||
| 47363b8d23 | |||
| 752653e243 | |||
| 6887f5d951 | |||
| a140980b1d |
+411
@@ -1,5 +1,320 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Added
|
||||
|
||||
- **1ST: a first-person camera, played like a modern one.** A seventh
|
||||
rung on the VOXEL ladder (hotkey 3 walks it; the OPTIONS row carries
|
||||
it). Stepping onto it dives the camera from wherever the orbit was
|
||||
into the player's own head over half a second, and stepping off flies
|
||||
it back out. The rig rides the same placed-camera seam the staged
|
||||
battle proved out, so the sky's bands meet the horizon, the sun and
|
||||
moon hang where their shadows say, and the water reflects at eye
|
||||
level -- all through math that was already there.
|
||||
|
||||
- **Free look.** Relative mouse motion (the cursor is captured while
|
||||
the rung is on; left click is A, right click is B), the right
|
||||
stick at a rate with a squared response curve, or a touch dragged
|
||||
across any open screen -- the overlay's d-pad and buttons still
|
||||
work, and a second finger can drag the view while the first
|
||||
walks. Pitch clamps short of straight up and straight down.
|
||||
|
||||
- **Free movement.** While 1ST drives, the grid walk is replaced by
|
||||
a continuous, camera-relative one: push forward and you go where
|
||||
you look, at any angle, sliding along whatever you graze. The left
|
||||
stick's raw deflection, the touch d-pad's true vector, or the held
|
||||
keys (forward / backpedal / strafe) all steer it. The grid is
|
||||
still the game: the walk asks the engine's own collision the same
|
||||
per-cell questions a grid step asks, the logical cell tracks the
|
||||
body, and every cell crossed runs the engine's own landing
|
||||
pipeline -- warps, encounters, spinners, gates, poison, repel, the
|
||||
step counters. Walking off the map edge, into a ledge or into a
|
||||
boulder hands the push to the engine's own handlers, so
|
||||
connections cross, ledges hop and boulders shove exactly as
|
||||
themselves. Speed is the grid walker's own (bike included), so
|
||||
distance per second and encounters per tile are unchanged.
|
||||
|
||||
- **Billboards seen from inside the world.** Character cards stop
|
||||
leaning and start turning: upright, yawed about their feet to face
|
||||
the eye, wearing the frame their pose shows *this* viewer -- walk
|
||||
behind an NPC and you see their back, circle to a flank and you
|
||||
get the profile, exactly the four frames Gen 1 drew. The authored
|
||||
figures (the couch sitters) turn the same way, about their own
|
||||
middle. The sun pass swaps frames in step, so a card never reads
|
||||
its own shadow through a mirror-flipped record of itself. The
|
||||
player's own card is left out of the camera draw -- the eye stands
|
||||
in it -- but still casts its shadow on the ground ahead.
|
||||
|
||||
- The shadow map's box follows the look (the orbit's fit reaches far
|
||||
north and barely south, which is wrong for a head facing south);
|
||||
the world curve is declined outright while the head owns the
|
||||
camera; and the whole rung falls back to the 75-degree orbit on
|
||||
hardware without the 3D pass.
|
||||
|
||||
## 1.4.3
|
||||
|
||||
### Added
|
||||
|
||||
- **The furniture of the whole game goes through the building
|
||||
pipeline.** 1.4.1 put four drawings through it; this is the rest of
|
||||
the rooms. Every one of them is the same read -- the drawing's own
|
||||
bands say what is a top seen from above, what is a face seen head-on,
|
||||
and where the thing ends on the floor -- and every one of them
|
||||
replaces a pinned box that wore its drawing as a decal. The pins all
|
||||
stay as the degradation path, neutralized wherever a template stamps.
|
||||
|
||||
- **The bookcase, the commonest piece of furniture in the game** --
|
||||
58 placements across two drawings on the town-house atlas (books
|
||||
and a bowl on each shelf at the west end of eighteen homes, books
|
||||
on both at the east), plus Red's and the Copycat's pair. Pinned
|
||||
`desk` it was a 24px box with the books painted on its flat front.
|
||||
Modelled it is 23 voxels of cabinet with its top seen from above,
|
||||
and every book, bowl and door panel sunk a voxel behind the frame
|
||||
the drawing seals it in.
|
||||
- **Celadon's display cabinets** -- the tall one with the trophy
|
||||
behind its glass and the short one beside it, band for band the
|
||||
same object as the town house's on another atlas, which is what
|
||||
makes the pair read as one line of furniture: 23 voxels and 15,
|
||||
exactly the 8 rows of drawing between them.
|
||||
- **The dining table, everywhere it is drawn** -- the generic town
|
||||
house's at 18 placements, Red's and the Copycat's, and the chief's
|
||||
long table at four cells wide. All of them the lab table's read at
|
||||
a different width, all of them 6 voxels, all of them standing on
|
||||
the ground line their legs are drawn stopping at rather than on
|
||||
the grid's floor.
|
||||
- **The stool at every one of those tables** -- 94 placements on the
|
||||
house atlas alone, ten more in Red's and the Copycat's, and the Fan
|
||||
Club's four members' chairs, a different drawing that is
|
||||
pixel-identical from the seat down. The first template with no base
|
||||
piece at all: a stool is drawn mid-cell over its own floor, so it
|
||||
is a desk-set of exactly one part, seat lid over legs with the
|
||||
floor showing between them.
|
||||
- **The Pokemon Center's healing machine** -- two variants, 24
|
||||
placements, plus the Indigo Plateau lobby's pair. A wall-height
|
||||
cabinet with its monitor perched on the front of its top face,
|
||||
drawn across two map rows because it towers over the 16px band
|
||||
behind it, which the volume path could only read as more wall. The
|
||||
hoses leaving its side are modelled as hoses, at the elevation and
|
||||
the depth the two stacked motifs put them; the west machine's
|
||||
keyboard is a shelf at counter height wearing its own top-view art.
|
||||
- **Bill's desk, and the Silph president's** -- the same drawing in
|
||||
both rooms. Its terminal is drawn in 2:1 isometric, turned 45
|
||||
degrees to the map, and builds as a cube rather than the slab a 2:1
|
||||
reading gives; the kinked dark run between keyboard and computer is
|
||||
raised to the keyboard's height and reads as the cable it is. The
|
||||
desk stops at its own two cells because the artist drew its apron
|
||||
into the walkable cell in front, sharing tiles with the chair
|
||||
pushed up to it -- so the chair is modelled as a part of the desk.
|
||||
- **The Bike Shop's open toolbox.** The drawing looks down INTO the
|
||||
tray, which is why every solid treatment failed it -- as a
|
||||
`billboard` the whole cell went up as one 10-voxel slab wearing the
|
||||
drawing as a decal. `tray` builds four walls, a floor and air
|
||||
between them, with the lid standing open on its hinge.
|
||||
|
||||
What the template language grew to carry them: `tray`; a `desk` band
|
||||
that lays its top face flat as a lid; the `box`, `flat` and `iso`
|
||||
part kinds; `stretch` for a band mapped over a deeper plot than it
|
||||
was drawn on; `inset` for a pane sunk by hand; `panes = false` where
|
||||
the global recess pass has the polarity backwards; a `wall` element
|
||||
so a template can keep the band behind it solid; `plane` for a height
|
||||
the drawing states elsewhere; and `scrub`/`keep`/`support`, which let
|
||||
a template model a surface while leaving an object standing on it to
|
||||
its own standee -- Red's potted plant on the dining table.
|
||||
|
||||
- **Round bins: the `can` class.** Vermilion Gym's switch puzzle stands
|
||||
fifteen galvanised trash cans in a row, and the S.S. Anne redraws the
|
||||
same object pixel for pixel as its galley barrels. Left to the thin
|
||||
standee pool they were flat discs on edge -- fifteen coins standing
|
||||
in a row; pinned a plain `cylinder` the drawing's base arc revolves
|
||||
too and they came out as barrels balanced on a three-voxel stem.
|
||||
`can` is the round hull cut at both ends, hollowed and tapered: the
|
||||
drawn mouth ellipse projects across the top and down the well so you
|
||||
look into the bin, the drawn base ellipse is ground contact rather
|
||||
than body, and the plan narrows toward the floor. The two ellipses
|
||||
are measured off the pixels; the height, the well and the taper are
|
||||
authored, and the entry says why.
|
||||
|
||||
- **The rock gyms' boulders are round.** 87 placements over Pewter's
|
||||
walls and maze and Bruno's clusters, and every one of them was a
|
||||
square bar wearing a boulder texture in relief -- the repeat-aware
|
||||
scenery path extruding the whole drawing as one course. Each cell is
|
||||
now a hull whose plan is its own drawn width profile turned in depth:
|
||||
a dome full-width from the drawn shoulder down, tapering over the top
|
||||
five rows exactly where the art tapers, with the floor's corner
|
||||
diamonds opening between them the way the drawing has them. Still
|
||||
16px, so nothing standing on or beside a rock moves.
|
||||
|
||||
- **The potted plant stands as a plant.** The most repeated interior
|
||||
prop in the game -- 78 placements over 13 maps, six per Pokemon
|
||||
Center -- and its urn was rendering as a hollow black frame, because
|
||||
the drawing's foot lies flush on the block's bottom edge and the
|
||||
background vote took the plant's own darks away with the floor. Named
|
||||
outright as light and white instead, it stands as one organic
|
||||
silhouette 32px tall over its two stacked cells, crown overhanging
|
||||
the stem. `planter` carries the same reading for a round drawing
|
||||
stacked two cells high on one cell of plot.
|
||||
|
||||
- **Bicycles, in both places the Bike Shop draws them.** The six on the
|
||||
showroom floor get their own pool at two voxels rather than the thin
|
||||
pool's five: a bike is a line drawing, and at five voxels every
|
||||
stroke closes the gap to its neighbour with its own side faces, so
|
||||
from any angle but dead-on the air inside the frames filled in and
|
||||
the six came out as one dark lump. And the two against the north wall
|
||||
get `mounted`, a new authored-mask escape for a thing drawn INTO a
|
||||
wall band: it holds the wall's plane as a thin per-pixel slab instead
|
||||
of standing up as a sprite card, and it keeps its drawn elevation, so
|
||||
a bicycle hung clear of the floor stays hung. Its mask is measured
|
||||
rather than hand-drawn -- the plain panel tile composited across the
|
||||
same grid and the background flooded in through the pixels that still
|
||||
match it, which separates bicycle from stripe exactly.
|
||||
|
||||
- **The Marts' cash register is a machine, not a decal.** An authored
|
||||
figure may now state a `depth`, which makes it an object rather than
|
||||
a person: a per-pixel solid standing on the counter instead of the
|
||||
flat card that turned edge-on with the camera. And the drawing is not
|
||||
a box -- its black linework packs two facings, an L of base and arm
|
||||
around a keypad that is the machine's deck seen from above. `flat`
|
||||
lays that rect horizontal in the notch of the L, and `thin` gives the
|
||||
receipt curl a paper's thickness where the body's would have made it
|
||||
a wedge.
|
||||
|
||||
- **Shelf fronts have relief.** Everything the `bookcase` collapse is
|
||||
used for is a shelf, a rack or a display case, and all of them seal
|
||||
their contents behind the drawing's own black frame -- so those
|
||||
regions now sink a voxel, the same rule a facade's window panes are
|
||||
recessed by, and the books stand in the shelf instead of being
|
||||
painted on it. A tileset that borrows the collapse for something that
|
||||
is not a shelf says `bookcase_relief = false`: the League's masonry
|
||||
and pilasters, whose courses are the wall itself, and Bill's
|
||||
transporter drums, whose light regions are a lit barrel.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Class heights now follow the models under them.** A tileset's
|
||||
`heights` gets stools at 5 and tables at 6 in the houses, Bill's desk
|
||||
at 8, and cans at 9 -- each of them the drawn elevation the new
|
||||
template or hull stands at, so whoever sits on a stool sits on the
|
||||
seat, and whatever object sprite stands on a table lands on the
|
||||
modelled top rather than three voxels over it or under it.
|
||||
- The healing machines' two flanks leave the `wall` pin for the thin
|
||||
standee pool. They are equipment standing beside the console -- a
|
||||
pair of pipes and a keyboard -- and as wall each was boxed into a
|
||||
solid 16px half-cell wearing its drawing in relief.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Water no longer hides behind water.** The reflective pass writes no
|
||||
depth -- the depth canvas is detached for the length of it so the
|
||||
shader can read it -- so nothing put a lake in the buffer and no lake
|
||||
could occlude another; the sheets were simply painted in mesh order.
|
||||
Flat water never showed it, one plane, a farther sheet always landing
|
||||
farther down the screen. The world curve ends that: it drops the far
|
||||
side of the map into the near field of view, and a sea a hundred and
|
||||
fifty tiles away came out rasterised on top of the pond at the
|
||||
player's feet, tall grass and all -- water and terrain "from the
|
||||
other side of the map", not reflected but there. The water meshes now
|
||||
go down flat first, through the ordinary scene shader with depth
|
||||
writes on, and the reflective pass draws over what survived. The
|
||||
buffer holds the surface, so the pass's own test throws the far sheet
|
||||
away; the reflection copy holds it too, so a ray grazing another part
|
||||
of the lake reads water rather than the void behind it; and a frame
|
||||
that cannot run the pass at all is unchanged, because the flat draw
|
||||
is the fallback that was already there.
|
||||
- **Reflections under the world curve.** The bend tips the world away
|
||||
and the things standing on it do not lean with it -- and a lake is
|
||||
one of those things. Reflected off the bowl the bend makes, the far
|
||||
half of a pond was a mirror tilted twenty degrees: it threw the ray
|
||||
past the vertical, where the sky ramp's own measure swings from one
|
||||
end to the other across a single column, and hard-edged patches of
|
||||
the wrong sky stamped into the water; the same tilt sent the
|
||||
screen-space march grazing along the bank rather than over it, which
|
||||
is what smeared the dock and the roofs across the harbour. What the
|
||||
water reflects is now worked out in the flat world, exactly as it
|
||||
would be with the curve off, and every marched sample is bent on its
|
||||
way to the screen by the vertex stage's own displacement -- so the
|
||||
ray is straight where it should be and lands where the geometry did.
|
||||
The wave columns are read on the flat sheet too: the relief walk is
|
||||
built on an even slab over a level plane, and in the curved world
|
||||
that slab is a bowl, which handed back a column a pixel or three off
|
||||
per fragment -- a patch of noise in the middle of a pond.
|
||||
- **Merged runs tore open under the curve.** A quad's interior is the
|
||||
chord of a parabola its neighbours draw the arc of, so a long run
|
||||
hangs below the short quads butted against it. Nothing bounded a
|
||||
run's length, and the ones that ran away were those wearing a
|
||||
constant texel -- a roof's black eave outline, its fascia, its shaded
|
||||
underside -- because a flat run has no art to break it. At 102px
|
||||
across a gym the eave tore off the roof and the slot showed the
|
||||
building's dark interior through it. Runs now stop at the next 8px
|
||||
lattice line, which is the lattice buildings are stamped on and the
|
||||
one every other quad in the scene already ends on, so every join is
|
||||
vertex-for-vertex and the bend carries them together. It costs quads
|
||||
whether the curve is on or not -- Cerulean's object stream goes from
|
||||
35.7k to 41.6k -- and that is deliberate: the mesh is cached per map
|
||||
and built over seconds, so meshing for the curve's sake only when the
|
||||
curve is on would mean rebuilding every live map on a keypress.
|
||||
|
||||
## 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
|
||||
|
||||
- **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.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Added
|
||||
@@ -116,6 +431,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
|
||||
|
||||
@@ -216,6 +597,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
|
||||
|
||||
@@ -32,11 +32,26 @@ clear ground — shot over the shoulder, the player's mon low and left and
|
||||
the enemy high and right, with a slow parallax drift behind them and a
|
||||
depth-of-field pass that keeps both of them sharp.
|
||||
|
||||
Purely presentational. Nothing here reaches collision, movement, triggers
|
||||
or scripts — it changes what the world *looks* like and nothing about what
|
||||
it *is*. The battle arena is where the **camera** goes, not where anybody
|
||||
goes: no cell, facing, flag or warp is written, so the player is standing
|
||||
exactly where the fight found them when it ends.
|
||||
And the whole thing from inside. The ladder's top rung, **1ST**, dives the
|
||||
camera into the player's own head: free look on the mouse (captured while
|
||||
the rung is on — left click is A, right click is B), the right stick, or a
|
||||
touch dragged across open screen; free movement that goes where you look,
|
||||
at any angle, sliding along walls — the left stick's raw deflection, the
|
||||
touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn
|
||||
to face the eye wearing the frame their pose shows *this* viewer — walk
|
||||
behind someone and you see their back — and the sky, the shadows and the
|
||||
water reflections all carry over, because the head rides the same placed
|
||||
camera the battle shot proved out.
|
||||
|
||||
Presentational, with one deliberate exception. Every rung but 1ST changes
|
||||
what the world *looks* like and nothing about what it *is*; the battle
|
||||
arena is where the **camera** goes, not where anybody goes. 1ST replaces
|
||||
the grid walk with a free one while it is selected — but even there the
|
||||
game is untouched: the walk asks the engine's own collision the same
|
||||
questions a grid step asks, keeps the player's cell synced, and runs the
|
||||
engine's own landing pipeline per cell crossed, so warps, encounters,
|
||||
ledges, gates and scripts all fire exactly as themselves. Step off the
|
||||
rung and the grid walk is back.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -52,6 +67,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
|
||||
|
||||
+1336
-87
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+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
|
||||
|
||||
+632
-16
@@ -68,6 +68,47 @@ local RECESS_MAX = 24
|
||||
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
|
||||
side = 0.78, bottom = 0.5 }
|
||||
|
||||
-- ------- how far a merged run may reach: the tile lattice
|
||||
--
|
||||
-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
|
||||
-- straight projection a run may be as long as it likes -- a straight line
|
||||
-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
|
||||
-- STRAIGHT. It drops every vertex by the square of its distance from the
|
||||
-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
|
||||
-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
|
||||
-- the short quads butted against it, and the join tears open.
|
||||
--
|
||||
-- Nothing bounded a run's length before, and the runs that ran away were
|
||||
-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
|
||||
-- fascia, the shaded underside -- because a flat run has no art to break
|
||||
-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
|
||||
-- three world pixels under the roof surface beside it: the eave tore off
|
||||
-- the roof and the drop showed the building's dark interior through the
|
||||
-- slot. (Strip runs, the drawing marching along the atlas, break at the
|
||||
-- tileset's own boundaries and were never the problem.)
|
||||
--
|
||||
-- So a run stops at the next 8px lattice line. Buildings are stamped at
|
||||
-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
|
||||
-- scene -- terrain, props, this -- now ends on the same lines, every join
|
||||
-- is vertex-for-vertex, and the bend carries them together. What is left
|
||||
-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
|
||||
-- world pixel at any rung.
|
||||
--
|
||||
-- It costs quads on a dense city map (Cerulean's object stream goes from
|
||||
-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
|
||||
-- whether the curve is on or not, which is the deliberate trade: the mesh
|
||||
-- is cached per map and built asynchronously over seconds, so meshing for
|
||||
-- the curve's sake only when the curve is on would mean rebuilding every
|
||||
-- live map on a keypress.
|
||||
local CELL = 8
|
||||
|
||||
-- How far a run starting at `a` may go before it crosses the next lattice
|
||||
-- line. Floor-mod, so the awning's negative z lands on the same lines the
|
||||
-- positive side does.
|
||||
local function runCap(a)
|
||||
return CELL - a % CELL
|
||||
end
|
||||
|
||||
local function keyOf(tx, ty)
|
||||
return (ty + 64) * 4096 + (tx + 64)
|
||||
end
|
||||
@@ -170,6 +211,39 @@ local function read(t, data, perRow)
|
||||
|
||||
local inside = {}
|
||||
for i = 0, W * H - 1 do inside[i] = not outside[i] end
|
||||
|
||||
-- `scrub` names pixel rects where the drawing paints an object standing
|
||||
-- ON the surface (Red's potted plant on the dining tabletop). The object
|
||||
-- keeps its own standee -- the template's `keep` leaves its tiles
|
||||
-- unclaimed -- so the band beneath it is the one surface the drawing
|
||||
-- implies but never paints clear: every rect pixel takes the field
|
||||
-- shade, sourced from the first field texel outside the rects, and the
|
||||
-- model's top comes out as the plain surface the object sat on.
|
||||
if t.scrub then
|
||||
local function inRect(x, y)
|
||||
for _, r in ipairs(t.scrub) do
|
||||
if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local donor = nil
|
||||
for i = 0, W * H - 1 do
|
||||
if col[i] == GREY and inside[i]
|
||||
and not inRect(i % W, math.floor(i / W)) then
|
||||
donor = i
|
||||
break
|
||||
end
|
||||
end
|
||||
for i = 0, W * H - 1 do
|
||||
if inRect(i % W, math.floor(i / W)) then
|
||||
col[i] = GREY
|
||||
ax[i], ay[i] = ax[donor], ay[donor]
|
||||
inside[i] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
|
||||
end
|
||||
|
||||
@@ -190,7 +264,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
|
||||
@@ -259,6 +350,14 @@ local function measure(sp, t)
|
||||
end
|
||||
end
|
||||
|
||||
-- The pane rule reads a LIGHT region the drawing seals behind a BLACK
|
||||
-- frame. A drawing built the other way round -- the healing machine's
|
||||
-- dark screens sealed behind their own white bezels -- inverts under
|
||||
-- it: every lit edge sinks and the black panes stand proud, a black
|
||||
-- lattice a voxel off the face. `panes = false` says the drawing does
|
||||
-- not carry the rule's polarity, so the facade stays flush.
|
||||
if t.panes == false then recess = {} end
|
||||
|
||||
-- One representative texel per shade, taken from the building's own art:
|
||||
-- the roof's fascia and its undersides are geometry the drawing implies
|
||||
-- but never paints, and they must still wear its palette (and pick up
|
||||
@@ -279,20 +378,510 @@ 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.
|
||||
-- `depth` names the plot in TILE ROWS, which is the right grain for a
|
||||
-- building. `depthPx` names it in voxels, for an object whose real
|
||||
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
|
||||
-- standing in the middle of its own cell, not a thing that fills a plot.
|
||||
return { top = top, ytop = ytop,
|
||||
D = t.depthPx or ((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
|
||||
|
||||
-- The parts list, shared by every base piece: a desk plane or an
|
||||
-- open tray rim alike, `plane` is simply the height they ride.
|
||||
local ytop = 0
|
||||
local function buildParts(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). `at` names the sheet's own height when it does
|
||||
-- not lie on the desk plane (the healing machine's keyboard is a
|
||||
-- shelf mounted on the cabinet's side); `thick` gives it a body
|
||||
-- -- layers below the sheet repeating each column's own texel,
|
||||
-- the same continuation rule every synthesized surface follows.
|
||||
local r0 = p.rows[1]
|
||||
local z0 = p.z or r0
|
||||
local atY = p.at or plane
|
||||
local thick = p.thick or 1
|
||||
if atY > ytop then ytop = atY end
|
||||
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
|
||||
for y = math.max(0, atY - thick + 1), atY do
|
||||
put(sx, y, z, sy * W + sx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "box" then
|
||||
-- A BOX part is a drawn rect standing at its own drawn
|
||||
-- elevation -- equipment attached to the machine rather than an
|
||||
-- object on the desk plane. The rows are face-on art: the top
|
||||
-- row's drawn height IS the box's top (ground - 1 - r0,
|
||||
-- measured), and the box runs down to `base` (default the drawn
|
||||
-- extent; 0 continues it to the floor, the legs-continue rule).
|
||||
-- Height beyond the drawn rows fills the way a roof band does:
|
||||
-- rows before `cycle` map 1:1 from the top, rows after it 1:1
|
||||
-- from the bottom -- the healing machine hoses' foot lands ON
|
||||
-- the floor -- and the cycle window repeats between.
|
||||
local r0, r1 = p.rows[1], p.rows[2]
|
||||
local c0 = p.cycle and p.cycle[1] or r1
|
||||
local c1 = p.cycle and p.cycle[2] or r1
|
||||
local pz = p.z or 0
|
||||
local pd = p.depth
|
||||
local top = pr.ground - 1 - r0
|
||||
local bot = p.base or (pr.ground - 1 - r1)
|
||||
local nTop, nBot = c0 - r0, r1 - c1
|
||||
if top > ytop then ytop = top end
|
||||
for y = bot, top do
|
||||
local k, j = top - y, y - bot
|
||||
local sy
|
||||
if k < nTop then
|
||||
sy = r0 + k
|
||||
elseif j < nBot then
|
||||
sy = r1 - j
|
||||
else
|
||||
sy = c0 + (k - nTop) % (c1 - c0 + 1)
|
||||
end
|
||||
for sx = x0, x1 do
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local ix = interiorAt(sx, sy, x0, x1)
|
||||
for z = pz, pz + pd - 1 do
|
||||
if z >= 0 and z < D then
|
||||
local px = (z == pz or z == pz + pd - 1) and sx or ix
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "iso" then
|
||||
-- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
|
||||
-- degrees to the map, so one rhombus carries its top, its front
|
||||
-- and its side at once and no band or facade split can reach
|
||||
-- them. Un-projecting it is that projection run backwards: the
|
||||
-- box stands as a real diamond in plan and every voxel wears the
|
||||
-- texel the drawing paints where that voxel projects TO. The
|
||||
-- drawn top lands on the top, the screen on the screen-facing
|
||||
-- side and the flank on the flank, and nothing is segmented by
|
||||
-- hand -- which is the only way to get this right, because the
|
||||
-- three faces meet on a diagonal no rectangle can name.
|
||||
--
|
||||
-- Everything but the depth centre falls out of the drawn rect,
|
||||
-- because the projection fixes it: the half-width is the drawn
|
||||
-- rhombus's x radius, HALF that again its z radius (2:1 is what
|
||||
-- makes it isometric), the near corner's drawn row is the base
|
||||
-- rhombus's front tip, and whatever drawn height is left once
|
||||
-- that rhombus is accounted for is the box's own height. Bill's
|
||||
-- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
|
||||
-- which puts its left corner's vertical edge at drawn rows
|
||||
-- 4..10, exactly where the drawing paints one.
|
||||
--
|
||||
-- `plan` is the one thing the drawing CANNOT state: 2:1 is the
|
||||
-- projection, not the object, so reading rz as the plan radius
|
||||
-- too builds a box half as deep as it is wide -- a slab, not the
|
||||
-- cube the drawing depicts. `plan` names the real z radius and
|
||||
-- the drawn row is scaled into it, so a cube is `plan = rx` and
|
||||
-- the drawing still lands on it pixel for pixel.
|
||||
local pr0, pr1 = p.rows[1], p.rows[2]
|
||||
local rx = math.floor((x1 - x0 + 1) / 2)
|
||||
local rz = math.floor(rx / 2)
|
||||
local plan = p.plan or rz
|
||||
local oy = pr1 - rz
|
||||
local h = oy - rz - pr0
|
||||
local ytp = plane + h
|
||||
if ytp > ytop then ytop = ytp end
|
||||
for sx = x0, x1 do
|
||||
-- doubled, so a rect of even width keeps its centre between
|
||||
-- two columns instead of limping one to the left
|
||||
local dx2 = 2 * sx - (x0 + x1)
|
||||
for dz = -plan, plan do
|
||||
local z = p.z + dz
|
||||
local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
|
||||
if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
|
||||
-- the plan row scaled back into the drawn rhombus
|
||||
local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
|
||||
for y = 0, h do
|
||||
local sy = oy + dzs - y
|
||||
local i = sy * W + sx
|
||||
if sy >= pr0 and sy <= pr1 and inside[i] then
|
||||
put(sx, plane + y, z, i)
|
||||
end
|
||||
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
|
||||
-- `rise` lifts a part off the desk's top plane and `z` names its
|
||||
-- back-most depth row (the field a flat part already carries). An
|
||||
-- object STANDING on a desk needs neither: it starts on the plane
|
||||
-- at the plot's back. The healing machine's console needs both --
|
||||
-- it stands in the FRONT map row of a grid whose back row is the
|
||||
-- wall band it leans against, and its screen head is MOUNTED on
|
||||
-- the console's front two voxels above the body's top. Both come
|
||||
-- off the drawing, not off taste.
|
||||
local base = plane + (p.rise or 0)
|
||||
local pz = p.z or 0
|
||||
local ytp = base + (fr1 - fr0)
|
||||
if ytp > ytop then ytop = ytp end
|
||||
-- `inset` sinks an authored pane one voxel: the pane rule
|
||||
-- applied by hand, for a part whose screen IS sealed behind its
|
||||
-- own black frame while the template's `panes = false` (set for
|
||||
-- the polarity-inverted panel elsewhere in the same drawing)
|
||||
-- blocks the global pass. Same mechanism as a recess: the front
|
||||
-- voxel is simply not placed.
|
||||
local ins = p.inset
|
||||
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. `stretch`
|
||||
-- maps the drawn band over the whole depth instead, the tray's
|
||||
-- rule: for a part authored DEEPER than its drawing (the house
|
||||
-- stool grown past its drawn seat), clamping would print the
|
||||
-- last row as a long smear off the back band's edge.
|
||||
for z = pz, pz + pd - 1 do
|
||||
local front = z == pz + pd - 1
|
||||
local sy
|
||||
if front then
|
||||
sy = fr0
|
||||
elseif p.stretch then
|
||||
sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
|
||||
/ (pd - 1)), tr1)
|
||||
else
|
||||
sy = math.min(tr0 + z - pz, tr1)
|
||||
end
|
||||
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 and z >= 0 and z < D then
|
||||
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
|
||||
end
|
||||
end
|
||||
-- the body: facade rows anchored to the part's own base
|
||||
for sy = fr0 + 1, fr1 do
|
||||
local y = base + (fr1 - sy)
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local ix = interiorAt(sx, sy, x0, x1)
|
||||
for z = pz, pz + pd - 1 do
|
||||
if z >= 0 and z < D then
|
||||
if z == pz + pd - 1 then
|
||||
local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
|
||||
and sy >= ins.rows[1] and sy <= ins.rows[2]
|
||||
if not sunk and not pr.recess[i] then put(sx, y, z, i) end
|
||||
elseif z == pz then
|
||||
put(sx, y, z, i)
|
||||
else
|
||||
put(sx, y, z, sy * W + ix)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A TRAY is an open container -- the drawing looks down INTO it, so its
|
||||
-- top-view band is not a lid but the inside of the box, and the model
|
||||
-- has to be hollow. Bands, all measured 1:1 like any other band table:
|
||||
-- `top` is the opening (drawn row -> depth row), `front` the near wall
|
||||
-- seen face-on (drawn row -> elevation), `x` the box's outer span and
|
||||
-- `inner` the opening's, so the difference between them is the wall.
|
||||
-- Four walls stand to the rim, the floor slab lies `floor` voxels thick
|
||||
-- under the opening, and the cavity between them is left as AIR -- which
|
||||
-- is the whole point, and what an extruded facade can never be. Parts (a
|
||||
-- standing lid) then ride the rim like any object on a desk's plane.
|
||||
if t.tray then
|
||||
local tr = t.tray
|
||||
local top0 = tr.top[1]
|
||||
local fr0, fr1 = tr.front[1], tr.front[2]
|
||||
local bx0, bx1 = tr.x[1], tr.x[2]
|
||||
local ix0, ix1 = tr.inner[1], tr.inner[2]
|
||||
local floor = tr.floor or 0
|
||||
local plane = fr1 - fr0 + 1 -- the rim: the wall's height
|
||||
-- Which drawn row lies at depth z. The far rim is the band's first
|
||||
-- row and the near rim the front wall's own, and the drawn inside
|
||||
-- STRETCHES over whatever depth is between them: a box deeper than
|
||||
-- its drawing has rows to spare is the ordinary case once the plot
|
||||
-- stops being the grid, and the alternative -- running out of rows
|
||||
-- and repeating the last one -- would print the wrench twice.
|
||||
local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside
|
||||
local span = math.max(1, D - 3) -- interior depth rows - 1
|
||||
local function trayRow(z)
|
||||
if z == 0 then return top0 end
|
||||
if z == D - 1 then return fr0 end
|
||||
return lo + math.floor((z - 1) * (hi - lo) / span)
|
||||
end
|
||||
for sx = bx0, bx1 do
|
||||
Budget.tick()
|
||||
for z = 0, D - 1 do
|
||||
local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
|
||||
for y = 0, (hollow and floor or plane - 1) do
|
||||
if hollow or y == plane - 1 then
|
||||
-- the opening seen from above: the tray's own floor and
|
||||
-- whatever lies in it -- and the rim is the same band where
|
||||
-- the wall meets it
|
||||
local i = trayRow(z) * W + sx
|
||||
if inside[i] then put(sx, y, z, i) end
|
||||
else
|
||||
-- the wall below the rim: the front band folded up it, the
|
||||
-- drawn face on the front and back layers and the de-outlined
|
||||
-- interior between, exactly as a facade extrudes.
|
||||
--
|
||||
-- NO recess pass here, and it must stay that way: a pane sinks
|
||||
-- by DELETING its front voxel so the one behind becomes the
|
||||
-- pane, and a container's wall is one voxel thick -- there is
|
||||
-- nothing behind it, so the front panel simply opened a hole
|
||||
-- straight into the box and you could see the wrench through it.
|
||||
local sy = fr1 - y
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local px = (z == 0 or z == D - 1) and sx
|
||||
or interiorAt(sx, sy, bx0, bx1)
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if plane > ytop then ytop = plane end
|
||||
buildParts(plane)
|
||||
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
|
||||
|
||||
-- No base piece at all: the drawing IS its parts (the house stool -- a
|
||||
-- seat and its legs, nothing under them but floor). The plane the parts
|
||||
-- anchor to is the ground itself.
|
||||
if not t.desk then
|
||||
buildParts(0)
|
||||
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 desk's top plane. Usually the drawing states it: the fascia and
|
||||
-- base rows it paints below the objects ARE the front face, and their
|
||||
-- row count is the height. Bill's desk paints neither inside its grid
|
||||
-- -- its apron is drawn into the WALKABLE cell in front, and that cell
|
||||
-- is left out on purpose so the chair standing there keeps its own
|
||||
-- tiles -- so `plane` names the height directly and the body below the
|
||||
-- lid is synthesized: the band table's own rim treatment, a shaded box
|
||||
-- closed by the outline where it meets the floor, in the drawing's
|
||||
-- shades via shadeTexel.
|
||||
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 desk's own PLOT, when the grid holds more than the desk. Bill's
|
||||
-- grid runs on into the walkable cell, because the drawing puts the
|
||||
-- desk's apron AND the chair pushed up to it in the same tiles -- so
|
||||
-- the desk box has to stop at its own cell (`depth`) and stand on its
|
||||
-- own ground line rather than the grid's, which the chair's feet set
|
||||
-- eight rows lower. The base band's last row IS that ground line by
|
||||
-- definition, and for every desk drawn inside its own grid it is the
|
||||
-- measured one to the row (lab table, lab computers, Center PC, the
|
||||
-- Bike Shop toolbox), so this changes nothing for them.
|
||||
-- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
|
||||
-- is shallower than a tile row and leans against something: the
|
||||
-- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
|
||||
-- plus the front edge -- standing against the wall band, so its box
|
||||
-- runs z 16..25 of a 32-deep plot.
|
||||
local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
|
||||
local dz0 = t.desk.z or 0
|
||||
local dz1 = dz0 + deskD - 1
|
||||
local deskG = b1 + 1
|
||||
-- ...and the desk's COLUMNS (`x`), when the grid is wider than the
|
||||
-- desk: the healing machine's grid carries its flanking hoses and
|
||||
-- keyboard, and the cabinet is only the middle 16 columns.
|
||||
local dx0 = t.desk.x and t.desk.x[1] or 0
|
||||
local dx1 = t.desk.x and t.desk.x[2] or W - 1
|
||||
|
||||
-- The WALL element: the band the machine backs onto, whose tiles this
|
||||
-- grid claims. The drawing shows it only as the stripe background
|
||||
-- around the tower (the same standing as the potted plants' floor),
|
||||
-- so the block cycles the drawing's own stripe unit -- real pixels of
|
||||
-- column `x`, rows `cycle` -- at wall-band height over the back plot,
|
||||
-- exactly what the neighbouring cells' `wall` pins render.
|
||||
if t.wall then
|
||||
local wl = t.wall
|
||||
local c0, c1 = wl.cycle[1], wl.cycle[2]
|
||||
local cn = c1 - c0 + 1
|
||||
local wx = wl.x or 0
|
||||
for y = 0, wl.h - 1 do
|
||||
Budget.tick()
|
||||
local sy = c0 + (wl.h - 1 - y) % cn
|
||||
for sx = 0, W - 1 do
|
||||
for z = 0, wl.depthPx - 1 do
|
||||
put(sx, y, z, sy * W + wx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the base band, extruded exactly like every lab table's
|
||||
for sy = b0, b1 do
|
||||
Budget.tick()
|
||||
local y = deskG - 1 - sy
|
||||
for sx = dx0, dx1 do
|
||||
if inside[sy * W + sx] then
|
||||
local ix = interiorAt(sx, sy, dx0, dx1)
|
||||
for z = dz0, dz1 do
|
||||
local px = (z == dz0 or z == dz1) 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)
|
||||
local sx = i % W
|
||||
if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then
|
||||
vox[key(sx, deskG - 1 - sy, dz1)] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- the slab: fascia rows wrap every side
|
||||
for sy = f0, f1 do
|
||||
Budget.tick()
|
||||
local y = plane - 1 - (sy - f0)
|
||||
for sx = dx0, dx1 do
|
||||
for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
|
||||
end
|
||||
end
|
||||
|
||||
if t.desk.top then
|
||||
-- The lid wears the desk's own drawn top band -- the drawing DOES
|
||||
-- paint this tabletop (the healing machine's white top face with
|
||||
-- its lit west and shaded east strips), so nothing is synthesized
|
||||
-- where it is visible: band rows map back-to-front, the first
|
||||
-- fascia row is the drawn front-top edge, same rule as an upright
|
||||
-- part's lid. Where a part's drawing occludes the band (the monitor
|
||||
-- standing on it), the lid continues the nearest strip BESIDE the
|
||||
-- part -- still the drawing's own pixels, the same sibling-pattern
|
||||
-- rule every synthesized lid follows.
|
||||
local tr0, tr1 = t.desk.top[1], t.desk.top[2]
|
||||
for z = dz0, dz1 do
|
||||
Budget.tick()
|
||||
local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
|
||||
for sx = dx0, dx1 do
|
||||
local px = sx
|
||||
for _, p in ipairs(t.parts) do
|
||||
local px0, px1 = p.x[1], p.x[2]
|
||||
local r0, r1
|
||||
if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then
|
||||
r0, r1 = p.rows[1], p.rows[2]
|
||||
else
|
||||
r0, r1 = p.top[1], p.facade[2]
|
||||
end
|
||||
if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
|
||||
px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
|
||||
px = math.max(dx0, math.min(dx1, px))
|
||||
break
|
||||
end
|
||||
end
|
||||
put(sx, plane - 1, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- the lid continues the sibling tables' top -- black rim, white
|
||||
-- highlight courses along the north and west, grey field
|
||||
local field = t.desk.lid == "white" and WHITE or GREY
|
||||
for sx = dx0, dx1 do
|
||||
for z = dz0, dz1 do
|
||||
local shade = field
|
||||
if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then
|
||||
shade = BLACK
|
||||
elseif sx == dx0 + 1 or z == dz0 + 1 then
|
||||
shade = WHITE
|
||||
end
|
||||
put(sx, plane - 1, z, pr.shadeTexel[shade])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if plane > ytop then ytop = plane end
|
||||
buildParts(plane)
|
||||
|
||||
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 +956,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
|
||||
@@ -464,7 +1055,8 @@ local function emit(m, sp, atlasW, atlasH)
|
||||
local function runX(y, z, dx, dy, dz, x)
|
||||
local i0 = ci(x, y, z)
|
||||
local strip, n = nil, 1
|
||||
while true do
|
||||
local cap = runCap(x)
|
||||
while n < cap do
|
||||
local nx = x + n
|
||||
local i = ci(nx, y, z)
|
||||
if not i or ci(nx + dx, y + dy, z + dz) then break end
|
||||
@@ -556,8 +1148,8 @@ local function emit(m, sp, atlasW, atlasH)
|
||||
while z <= zmax do
|
||||
local i = ci(x, y, z)
|
||||
if i and not ci(x + d, y, z) then
|
||||
local n = 1
|
||||
while z + n <= zmax do
|
||||
local n, cap = 1, runCap(z)
|
||||
while n < cap and z + n <= zmax do
|
||||
local j = ci(x, y, z + n)
|
||||
if j ~= i or ci(x + d, y, z + n) then break end
|
||||
n = n + 1
|
||||
@@ -666,7 +1258,7 @@ function Buildings.build(S, map, data, perRow)
|
||||
end
|
||||
built = models[key]
|
||||
end
|
||||
Buildings.stamp(S, map, built, tx, ty, bw, bh)
|
||||
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -676,9 +1268,24 @@ end
|
||||
|
||||
-- One placement: claim its tiles (so the detector leaves them alone and
|
||||
-- the mesher paints ground under them) and copy the model into place.
|
||||
function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
local shape = { class = "building", h = 0, art = "building",
|
||||
flat = false, authored = true }
|
||||
--
|
||||
-- Two template fields alter what a claim means, for a drawing that
|
||||
-- carries a STANDEE on its surface (Red's potted plant on the dining
|
||||
-- table). `keep` names tile ids the stamp must NOT claim: their authored
|
||||
-- pins stay live, so the standee scan still stands the object exactly as
|
||||
-- it always did. `support` is the model's top plane in voxels: the claim
|
||||
-- shape carries it as its height, which is what tells that scan the
|
||||
-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
|
||||
-- a building claim with height as a full model (skip, never a second
|
||||
-- box; see its support branches).
|
||||
function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
|
||||
local shape = { class = "building", h = (t and t.support) or 0,
|
||||
art = "building", flat = false, authored = true }
|
||||
local keep = nil
|
||||
if t and t.keep then
|
||||
keep = {}
|
||||
for _, id in ipairs(t.keep) do keep[id] = true end
|
||||
end
|
||||
|
||||
-- the ground the building stands on: the commonest flat tile around its
|
||||
-- feet, so a house on a path keeps its path
|
||||
@@ -704,9 +1311,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
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 keep and keep[S.tileAt[k]] then
|
||||
-- unclaimed by request: the tile keeps its pin (the plant's
|
||||
-- cutout pool) and the standee scan finds it there. Only the
|
||||
-- ground is set now, so the scan's own claim of these tiles has
|
||||
-- the building's floor to paint when no flat tile touches a
|
||||
-- cluster ringed by its own furniture.
|
||||
S.ground[k] = best or false
|
||||
else
|
||||
S.shapeAt[k] = shape
|
||||
S.skip[k] = true
|
||||
S.ground[k] = best or false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+15
-5
@@ -853,14 +853,24 @@ end
|
||||
-- character card (VoxelScene). A figure baked into the terrain mesh could
|
||||
-- not lean, and a shared mesh could not carry per-figure placement.
|
||||
--
|
||||
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
|
||||
-- none, so the loop that draws them is shorter than the terrain's.
|
||||
-- A list, not a mesh: `{ mesh, wx, wz, y, w }` per figure. Maps have one
|
||||
-- or none, so the loop that draws them is shorter than the terrain's.
|
||||
-- `w` is the card's own width in its local space (its quads start at
|
||||
-- x = 0), measured here because the first-person pass yaws a card about
|
||||
-- its middle -- a card yawed about its left edge swings off its seat.
|
||||
local function buildFigureMeshes(map)
|
||||
local out = {}
|
||||
for _, f in ipairs(Structures.forMap(map).figures or {}) do
|
||||
local mesh = quadsMesh(f.quads)
|
||||
if mesh then
|
||||
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
|
||||
local w = 0
|
||||
for _, q in ipairs(f.quads) do
|
||||
for c = 1, 4 do
|
||||
local x = q[c] and q[c][1]
|
||||
if x and x > w then w = x end
|
||||
end
|
||||
end
|
||||
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y, w = w }
|
||||
end
|
||||
end
|
||||
return out
|
||||
@@ -1132,8 +1142,8 @@ function ChunkMesher.flowers(map)
|
||||
return c and c.flowers or nil
|
||||
end
|
||||
|
||||
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
|
||||
-- own leaning matrix at draw time, so they cannot share one mesh.
|
||||
-- Authored figures as `{ mesh, wx, wz, y, w }` records -- each placed by
|
||||
-- its own leaning matrix at draw time, so they cannot share one mesh.
|
||||
function ChunkMesher.figures(map)
|
||||
local c = cache[map.id]
|
||||
local list = c and c.figures
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
-- Voxel world mode: the first-person camera -- the 1ST rung.
|
||||
--
|
||||
-- Every other rung is the same camera at a different pitch: an orbit over
|
||||
-- the view centre, described by one number. 1ST is a different rig
|
||||
-- entirely: the eye stands in the player's own head, the view direction is
|
||||
-- the player's to steer -- mouse, right stick or a touch drag -- and the
|
||||
-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle
|
||||
-- already proved out. Everything downstream of that seam -- the shader
|
||||
-- uniforms, project(), the sky's vanishing line, the water's lean -- reads
|
||||
-- eye and focus the same way it always has.
|
||||
--
|
||||
-- What this module owns:
|
||||
--
|
||||
-- the ATTITUDE yaw and pitch, fed by whichever look input speaks:
|
||||
-- relative mouse motion, the right stick's rate, or a
|
||||
-- touch dragged across open screen. All three drive the
|
||||
-- same two numbers, so they compose instead of fighting.
|
||||
--
|
||||
-- the BLEND easing between the orbit and the head. Stepping onto
|
||||
-- the rung dives the camera from wherever the orbit was
|
||||
-- into the player's eyes over half a second; stepping off
|
||||
-- flies it back out. Mid-blend the rig is a straight lerp
|
||||
-- of the two cameras -- eye, focus, fov, up -- through
|
||||
-- the same placed-camera record.
|
||||
--
|
||||
-- the MOVE INTENT the analog vector FreeMove walks the player by,
|
||||
-- gathered here because it is made of the same devices:
|
||||
-- the left stick's raw axes, the touch d-pad's true
|
||||
-- deflection, or the held keys, rotated by this camera's
|
||||
-- yaw so "forward" means "where I am looking".
|
||||
--
|
||||
-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the
|
||||
-- collision walk and the grid the game logic still lives on), and the
|
||||
-- billboard math that faces cards at this eye (VoxelScene, which owns
|
||||
-- every other card matrix too).
|
||||
--
|
||||
-- Everything the module reaches -- the mouse's relative mode, the wrapped
|
||||
-- love handlers, the touch overlay's hit test -- is pcall-guarded the same
|
||||
-- way the 3D pass is: headless runs and drivers without a mouse simply
|
||||
-- never see the input, and the rung falls back to holding the 75-degree
|
||||
-- orbit.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
|
||||
local FirstPerson = {}
|
||||
|
||||
-- ------- the rig's numbers
|
||||
--
|
||||
-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head,
|
||||
-- not the hat tip -- above the same ground-plus-lift the character card
|
||||
-- stands on, so surfing bobs and ledge hops carry the view with them.
|
||||
--
|
||||
-- FOV is wider than the diorama's ~53 degrees: inside the world, the
|
||||
-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter
|
||||
-- middle ground.
|
||||
--
|
||||
-- FOCUS_DIST is short on purpose: the placed-camera branch derives its
|
||||
-- near plane from |eye - focus| (dist * 0.05), and the eye walks within
|
||||
-- 2-3 world pixels of a wall face when sliding along it -- a far focus
|
||||
-- would push the near plane through the wall and clip a hole in it.
|
||||
FirstPerson.EYE_HEIGHT = 13
|
||||
FirstPerson.FOV = math.rad(65)
|
||||
FirstPerson.FOCUS_DIST = 24
|
||||
|
||||
-- Pitch limits, in radians below horizontal (positive looks DOWN). The
|
||||
-- world has no ceiling and the sky's bands sit low, so looking far up
|
||||
-- shows the void above the gradient; the up-range is clamped tighter than
|
||||
-- the down-range for that reason, not a technical one.
|
||||
FirstPerson.PITCH_DOWN = math.rad(70)
|
||||
FirstPerson.PITCH_UP = -math.rad(50)
|
||||
FirstPerson.PITCH_DEFAULT = math.rad(10)
|
||||
|
||||
-- how long the dive into (and out of) the head takes, in seconds
|
||||
FirstPerson.BLEND_TIME = 0.45
|
||||
|
||||
-- ------- look input tuning
|
||||
--
|
||||
-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per
|
||||
-- count, the conventional shooter default. STICK rates are radians per
|
||||
-- second at full deflection, with a squared response curve so small
|
||||
-- deflections aim and full ones turn. TOUCH_TURN is what one full screen
|
||||
-- width of drag turns, mobile-shooter convention.
|
||||
FirstPerson.MOUSE_SENS = 0.0032
|
||||
FirstPerson.STICK_YAW = 3.5
|
||||
FirstPerson.STICK_PITCH = 2.4
|
||||
FirstPerson.STICK_DEAD = 0.18
|
||||
FirstPerson.TOUCH_TURN = 2.2 * math.pi
|
||||
FirstPerson.MOVE_DEAD = 0.25
|
||||
|
||||
-- ------- state
|
||||
--
|
||||
-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite
|
||||
-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a
|
||||
-- facing converts to a yaw by table lookup.
|
||||
FirstPerson.yaw = 0
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
FirstPerson.blend = 0
|
||||
|
||||
local wasEngaged = false
|
||||
local stick = { x = 0, y = 0 } -- right stick, latest event values
|
||||
local mouseDX, mouseDY = 0, 0 -- relative counts since last update
|
||||
local lookTouch = nil -- { id, x, y } of the claimed finger
|
||||
local touchMove = nil -- the touch d-pad's analog deflection
|
||||
local captured = false -- mouse relative mode engaged by us
|
||||
|
||||
-- the placed-camera record this module last handed to Voxel3D, so passes
|
||||
-- that key behaviour off "is the first-person rig the one drawing" (the
|
||||
-- billboard yaw, the frame remap) can ask by identity rather than by mode
|
||||
-- -- the battle's own placed camera must never read as first person
|
||||
local rig = nil
|
||||
|
||||
local FACING_ANGLE = {
|
||||
down = 0,
|
||||
right = math.pi / 2,
|
||||
up = math.pi,
|
||||
left = -math.pi / 2,
|
||||
}
|
||||
local FACING_ORDER = { "down", "right", "up", "left" }
|
||||
|
||||
local function wrapPi(a)
|
||||
return (a + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
|
||||
local function ease(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 1ST rung is selected and the 3D pass can carry it.
|
||||
function FirstPerson.engaged()
|
||||
return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
|
||||
end
|
||||
|
||||
-- Whether first person should be READING the player's inputs right now:
|
||||
-- engaged, with the overworld on top of the stack (a menu, a dialog or a
|
||||
-- battle above it owns the buttons, exactly as it does for grid walking).
|
||||
function FirstPerson.driving()
|
||||
if not FirstPerson.engaged() then return false end
|
||||
local ok, top, ow = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return Game.stack and Game.stack:top(), Game.overworld
|
||||
end)
|
||||
return ok and top ~= nil and top == ow
|
||||
end
|
||||
|
||||
-- The eased blend, 0 at the orbit and 1 in the head.
|
||||
function FirstPerson.blendEased()
|
||||
return ease(FirstPerson.blend)
|
||||
end
|
||||
|
||||
-- The blend, but only while the free-roam pass's own rig is the placed
|
||||
-- camera. The battle scene places a camera of its own through the same
|
||||
-- seam, and its cards must keep their stage lean rather than yawing at a
|
||||
-- first-person eye that is not looking at them.
|
||||
function FirstPerson.cardBlend()
|
||||
if not rig or Voxel3D.camera ~= rig then return 0 end
|
||||
return ease(FirstPerson.blend)
|
||||
end
|
||||
|
||||
-- Whether the player's own card should be left out of the camera draw:
|
||||
-- deep enough into the blend that the card would fill the lens from
|
||||
-- inside. The sun pass keeps drawing it either way -- a first-person
|
||||
-- player still throws a shadow on the ground ahead.
|
||||
function FirstPerson.hidePlayer()
|
||||
return FirstPerson.cardBlend() > 0.9
|
||||
end
|
||||
|
||||
-- ------- attitude
|
||||
|
||||
-- Apply a look delta, in radians. Everything that turns the head funnels
|
||||
-- through here, so the clamps live once.
|
||||
function FirstPerson.lookBy(dyaw, dpitch)
|
||||
FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw)
|
||||
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
|
||||
math.min(FirstPerson.PITCH_DOWN,
|
||||
FirstPerson.pitch + dpitch))
|
||||
end
|
||||
|
||||
-- The view direction's flat compass facing, for everything that still
|
||||
-- thinks in the grid's four directions: the cell A interacts with, the
|
||||
-- sprite the sun sees, the direction a blocked slide bonks in.
|
||||
function FirstPerson.compassFacing()
|
||||
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
if math.abs(s) > math.abs(c) then
|
||||
return s > 0 and "right" or "left"
|
||||
end
|
||||
return c > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- The unit look direction, and its flat (ground-plane) part.
|
||||
local function lookDir()
|
||||
local cp = math.cos(FirstPerson.pitch)
|
||||
return math.sin(FirstPerson.yaw) * cp,
|
||||
-math.sin(FirstPerson.pitch),
|
||||
math.cos(FirstPerson.yaw) * cp
|
||||
end
|
||||
|
||||
function FirstPerson.lookFlat()
|
||||
return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
end
|
||||
|
||||
-- ------- billboards seen from inside the world
|
||||
--
|
||||
-- The diorama's cards face south and lean back by the camera's pitch --
|
||||
-- correct for a camera that always stands south. An eye that can stand
|
||||
-- ANYWHERE sees a south-facing card edge-on from the east, so in first
|
||||
-- person every card yaws about its feet to face the eye (cylindrical
|
||||
-- billboarding: upright, never tipping). VoxelScene blends its matrices
|
||||
-- between the two by cardBlend.
|
||||
|
||||
-- The yaw that turns a card's south-facing normal toward the eye.
|
||||
function FirstPerson.cardYaw(wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
if not eye then return 0 end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return 0 end
|
||||
return math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames an entity shows THIS eye: its facing
|
||||
-- rotated into the viewer's own frame, quantised. The flat game's frames
|
||||
-- are "how this pose looks from the south", so the apparent facing is the
|
||||
-- pose rotated by where the viewer actually stands -- walk behind an NPC
|
||||
-- and you see their back, circle to their flank and you see the profile,
|
||||
-- exactly as the four frames Gen 1 drew intend.
|
||||
function FirstPerson.apparentFacing(facing, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
local phi = FACING_ANGLE[facing]
|
||||
if not (eye and phi) then return facing end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return facing end
|
||||
local rel = wrapPi(phi - math.atan2(dx, dz))
|
||||
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
|
||||
return FACING_ORDER[idx + 1]
|
||||
end
|
||||
|
||||
-- ------- the move intent
|
||||
--
|
||||
-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+
|
||||
-- right), mz advances (+ forward). Whichever device is actually deflected
|
||||
-- answers -- the left stick's raw axes first (the engine quantises them to
|
||||
-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger,
|
||||
-- then the held keys. Magnitude caps at 1.
|
||||
function FirstPerson.moveVector()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local input = ok and Game.input or nil
|
||||
|
||||
local ax = input and input.stickAxis or nil
|
||||
if ax then
|
||||
local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y)
|
||||
if mag > FirstPerson.MOVE_DEAD then
|
||||
local t = math.min(1, (mag - FirstPerson.MOVE_DEAD)
|
||||
/ (1 - FirstPerson.MOVE_DEAD))
|
||||
return ax.x / mag * t, -ax.y / mag * t
|
||||
end
|
||||
end
|
||||
|
||||
if touchMove then
|
||||
local mag = math.sqrt(touchMove.x * touchMove.x
|
||||
+ touchMove.y * touchMove.y)
|
||||
if mag > FirstPerson.MOVE_DEAD then
|
||||
local t = math.min(1, mag)
|
||||
return touchMove.x / mag * t, -touchMove.y / mag * t
|
||||
end
|
||||
end
|
||||
|
||||
if input then
|
||||
local mx = (input:isDown("right") and 1 or 0)
|
||||
- (input:isDown("left") and 1 or 0)
|
||||
local mz = (input:isDown("up") and 1 or 0)
|
||||
- (input:isDown("down") and 1 or 0)
|
||||
if mx ~= 0 or mz ~= 0 then
|
||||
local mag = math.sqrt(mx * mx + mz * mz)
|
||||
return mx / mag, mz / mag
|
||||
end
|
||||
end
|
||||
return 0, 0
|
||||
end
|
||||
|
||||
-- Rotate a camera-space move into world space: forward is the flat look
|
||||
-- direction, strafe-right is its right hand. (cross(forward, up) with
|
||||
-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0,
|
||||
-- sin y): face south and your right hand points west.)
|
||||
function FirstPerson.moveWorld(mx, mz)
|
||||
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
return -c * mx + s * mz, s * mx + c * mz
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
|
||||
-- Runs from the pipeline's update hook, every frame whatever the level --
|
||||
-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse
|
||||
-- capture lifecycle, and the frame's stick-rate look.
|
||||
function FirstPerson.update(dt)
|
||||
local engagedNow = FirstPerson.engaged()
|
||||
|
||||
-- entering the rung: the head starts looking the way the sprite faces,
|
||||
-- pitched gently down -- the reading pose of the flat game
|
||||
if engagedNow and not wasEngaged then
|
||||
local ok, facing = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return Game.overworld and Game.overworld.player
|
||||
and Game.overworld.player.facing
|
||||
end)
|
||||
FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
end
|
||||
wasEngaged = engagedNow
|
||||
|
||||
-- the blend, held at flat until there is terrain to dive into -- the
|
||||
-- same wait Voxel.update keeps for the orbit tween, for the same reason
|
||||
local target = engagedNow and 1 or 0
|
||||
if target > FirstPerson.blend and FirstPerson.blend == 0
|
||||
and not Voxel.ready then
|
||||
target = 0
|
||||
end
|
||||
local step = dt / FirstPerson.BLEND_TIME
|
||||
if FirstPerson.blend < target then
|
||||
FirstPerson.blend = math.min(target, FirstPerson.blend + step)
|
||||
elseif FirstPerson.blend > target then
|
||||
FirstPerson.blend = math.max(target, FirstPerson.blend - step)
|
||||
end
|
||||
if FirstPerson.blend <= 0 and rig then
|
||||
-- fully out: let go of the placed camera (unless a battle already
|
||||
-- swapped its own in, which is not ours to clear)
|
||||
if Voxel3D.camera == rig then Voxel3D.camera = nil end
|
||||
rig = nil
|
||||
end
|
||||
|
||||
-- mouse capture follows engagement: captured whenever the rung is on and
|
||||
-- the window has focus, released the moment either ends. Checked against
|
||||
-- the live mode rather than toggled on edges, so a capture lost to the
|
||||
-- OS (alt-tab) re-arms itself on the next focused frame.
|
||||
local wantCapture = engagedNow
|
||||
if wantCapture and love.window and love.window.hasFocus then
|
||||
local okF, focus = pcall(love.window.hasFocus)
|
||||
wantCapture = okF and focus or false
|
||||
end
|
||||
if love.mouse and love.mouse.setRelativeMode then
|
||||
local okM, isRel = pcall(love.mouse.getRelativeMode)
|
||||
if okM and isRel ~= wantCapture then
|
||||
pcall(love.mouse.setRelativeMode, wantCapture)
|
||||
end
|
||||
captured = wantCapture
|
||||
end
|
||||
|
||||
local driving = FirstPerson.driving()
|
||||
|
||||
-- The mouse's counts, accumulated by the wrapped handler since the last
|
||||
-- tick; dropped unread while something else owns the screen.
|
||||
--
|
||||
-- The yaw sign is NEGATED, here and in every look input below: yaw grows
|
||||
-- south -> east -> north (the world runs +X east, +Z south, and the
|
||||
-- direction is (sin yaw, cos yaw)), which seen from behind the eye is a
|
||||
-- LEFT turn -- so "move the mouse right, look right" means subtracting.
|
||||
local dx, dy = mouseDX, mouseDY
|
||||
mouseDX, mouseDY = 0, 0
|
||||
if driving and (dx ~= 0 or dy ~= 0) then
|
||||
FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS,
|
||||
dy * FirstPerson.MOUSE_SENS)
|
||||
end
|
||||
|
||||
-- the right stick is a rate: radians per second, squared response so
|
||||
-- the first half of the throw aims and the rest turns
|
||||
if driving then
|
||||
local rx, ry = stick.x, stick.y
|
||||
local function curve(v)
|
||||
local a = math.abs(v)
|
||||
if a < FirstPerson.STICK_DEAD then return 0 end
|
||||
a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD)
|
||||
return (v < 0 and -1 or 1) * a * a
|
||||
end
|
||||
local cy, cp = curve(rx), curve(ry)
|
||||
if cy ~= 0 or cp ~= 0 then
|
||||
-- negated yaw for the same reason as the mouse above
|
||||
FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt,
|
||||
cp * FirstPerson.STICK_PITCH * dt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the rig itself
|
||||
|
||||
-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same
|
||||
-- arithmetic Voxel3D.viewProjection runs, restated here because the blend
|
||||
-- needs both ends as DATA. Kept textually tiny so the two cannot drift:
|
||||
-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular
|
||||
-- in the YZ plane.
|
||||
local function orbitRig(cx, cy, vh)
|
||||
local a = Voxel.angle
|
||||
local dist = Voxel.FOCAL * vh
|
||||
return { cx, dist * math.cos(a), cy + dist * math.sin(a) },
|
||||
{ cx, 0, cy },
|
||||
2 * math.atan(1 / (2 * Voxel.FOCAL)),
|
||||
{ 0, math.sin(a), -math.cos(a) }
|
||||
end
|
||||
|
||||
local lastEye = nil -- frozen head pose for player-less frames
|
||||
|
||||
-- Build this frame's placed camera and hand it to Voxel3D, plus the scene
|
||||
-- centre the curve and the depth reference should use. `me` is the
|
||||
-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and
|
||||
-- (cx, cy) the orbit's own view centre.
|
||||
--
|
||||
-- Returns nil with the blend fully out, which is the caller's signal to
|
||||
-- leave the orbit in charge.
|
||||
function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
local b = FirstPerson.blend
|
||||
if b <= 0 then
|
||||
if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end
|
||||
rig = nil
|
||||
return nil
|
||||
end
|
||||
local e = ease(b)
|
||||
|
||||
local head
|
||||
if me then
|
||||
head = { me.px + 8,
|
||||
(me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT,
|
||||
me.py + 8 }
|
||||
lastEye = head
|
||||
else
|
||||
head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy }
|
||||
end
|
||||
local lx, ly, lz = lookDir()
|
||||
local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST,
|
||||
head[2] + ly * FirstPerson.FOCUS_DIST,
|
||||
head[3] + lz * FirstPerson.FOCUS_DIST }
|
||||
|
||||
local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
|
||||
local function mix(p, q)
|
||||
return { p[1] + (q[1] - p[1]) * e,
|
||||
p[2] + (q[2] - p[2]) * e,
|
||||
p[3] + (q[3] - p[3]) * e }
|
||||
end
|
||||
local up = mix(oUp, { 0, 1, 0 })
|
||||
local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3])
|
||||
if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul
|
||||
else up = { 0, 1, 0 } end
|
||||
|
||||
-- the world curve eases out with the blend: standing inside the world,
|
||||
-- the bend that sells the diorama reads as the ground falling away. A
|
||||
-- true zero (curve declined) needs the field present -- nil would let
|
||||
-- Voxel3D fall back to the setting
|
||||
local k = WorldCurve.k(vh) * (1 - e)
|
||||
|
||||
rig = {
|
||||
eye = mix(oEye, head),
|
||||
focus = mix(oFocus, fpFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
}
|
||||
Voxel3D.camera = rig
|
||||
|
||||
-- the scene centre walks from the orbit's view centre to the head, so
|
||||
-- the curve's focus, the depth reference and the glint's travel follow
|
||||
-- the camera that is actually in charge
|
||||
local sx = cx + (head[1] - cx) * e
|
||||
local sy = cy + (head[3] - cy) * e
|
||||
return rig, sx, sy
|
||||
end
|
||||
|
||||
-- Where the shadow pass should centre its box: pushed along the flat look
|
||||
-- so the fitted frustum -- built for an orbit that always looks north --
|
||||
-- covers the ground THIS camera sees. The push is strongest looking
|
||||
-- south (the direction the orbit's box barely reaches) and scales with
|
||||
-- the blend.
|
||||
function FirstPerson.shadowCenter(sx, sy, vh)
|
||||
local e = FirstPerson.cardBlend()
|
||||
if e <= 0 then return sx, sy end
|
||||
local fx, fz = FirstPerson.lookFlat()
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local cap = (ShadowMap.FAR_CAP or 2.5) * vh
|
||||
return sx + fx * 0.6 * vh * e,
|
||||
sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e
|
||||
end
|
||||
|
||||
-- The first-person facts a shadow signature has to include: the sun's
|
||||
-- box is fitted around this camera, so turning the head or walking the
|
||||
-- blend has to re-fit it even standing still.
|
||||
function FirstPerson.signature()
|
||||
local b = FirstPerson.blend
|
||||
if b <= 0 then return "" end
|
||||
return table.concat({
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
}, ",")
|
||||
end
|
||||
|
||||
-- ------- input capture
|
||||
--
|
||||
-- The seams: relative mouse motion has no Game handler at all (the
|
||||
-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the
|
||||
-- right stick's axes are explicitly ignored by Input, and a touch
|
||||
-- anywhere off the overlay's controls dies in TouchControls. Each wrap
|
||||
-- forwards everything it does not claim, and claims only while first
|
||||
-- person is actually driving -- so with the rung off, every byte flows
|
||||
-- exactly where it always did.
|
||||
|
||||
local installed = false
|
||||
|
||||
function FirstPerson.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- ------- right stick
|
||||
do
|
||||
local inner = Game.gamepadaxis
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
if axis == "rightx" then stick.x = value
|
||||
elseif axis == "righty" then stick.y = value end
|
||||
return inner(self, joystick, axis, value)
|
||||
end
|
||||
end
|
||||
-- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL
|
||||
-- convention and Input already claims them; 3/4 are the usual right
|
||||
-- pair on the same class of device. Real gamepads are excluded -- they
|
||||
-- already spoke through the mapped rightx/righty above, and their RAW
|
||||
-- axis 3 is as likely a trigger as a stick.
|
||||
--
|
||||
-- Two more exclusions, both learned the hard way on Android, where this
|
||||
-- wrap runs BEFORE the engine's own generic-joystick guards:
|
||||
--
|
||||
-- the accelerometer arrives as a joystick named for what it is, with
|
||||
-- gravity pinning an axis well past any deadzone -- the same device
|
||||
-- Game:joystickaxis refuses for movement (#459), refused here by the
|
||||
-- same name test, or the view spins on its own the moment 1ST opens.
|
||||
--
|
||||
-- and a raw axis is only BELIEVED after it has been seen near centre
|
||||
-- once. A stick at rest sits at zero, so a real one earns trust with
|
||||
-- its first touch; a gravity-pinned sensor axis or a trigger resting
|
||||
-- at an extreme never centres and so never steers the look.
|
||||
local function isAccelerometer(joystick)
|
||||
local ok, name = pcall(function() return joystick:getName() end)
|
||||
return ok and type(name) == "string"
|
||||
and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
local rawCentred = {}
|
||||
do
|
||||
local inner = Game.joystickaxis
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
local mapped = joystick and joystick.isGamepad and joystick:isGamepad()
|
||||
if not mapped and (axis == 3 or axis == 4)
|
||||
and not isAccelerometer(joystick) then
|
||||
if math.abs(value) < 0.3 then rawCentred[axis] = true end
|
||||
if rawCentred[axis] then
|
||||
if axis == 3 then stick.x = value else stick.y = value end
|
||||
end
|
||||
end
|
||||
return inner(self, joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- mouse
|
||||
--
|
||||
-- love.mousemoved rather than a Game method, because the engine has no
|
||||
-- Game:mousemoved to wrap -- the callback in the project's main.lua is
|
||||
-- the one place relative counts arrive. Claimed only while captured;
|
||||
-- pass-through otherwise, including the mouse-as-touch path.
|
||||
do
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(x, y, dx, dy, istouch)
|
||||
if captured and not istouch then
|
||||
mouseDX = mouseDX + (dx or 0)
|
||||
mouseDY = mouseDY + (dy or 0)
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, dx, dy, istouch) end
|
||||
end
|
||||
end
|
||||
-- While the mouse is captured there is no cursor to click UI with, so
|
||||
-- the buttons become GB buttons: left is A, right is B -- through the
|
||||
-- overlay's own press path, which a rebind can never detach. What WE
|
||||
-- pressed is remembered per button, so the release always reaches the
|
||||
-- overlay even if the capture ended while the button was down --
|
||||
-- otherwise a click that outlives the rung strands A held forever.
|
||||
local mouseHeld = {}
|
||||
local MOUSE_BTN = { [1] = "a", [2] = "b" }
|
||||
do
|
||||
local inner = love.mousepressed
|
||||
love.mousepressed = function(x, y, button, istouch, presses)
|
||||
if captured and not istouch and MOUSE_BTN[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = true
|
||||
Input:overlayPressed(MOUSE_BTN[button])
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, button, istouch, presses) end
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = love.mousereleased
|
||||
love.mousereleased = function(x, y, button, istouch, presses)
|
||||
if mouseHeld[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = nil
|
||||
Input:overlayReleased(MOUSE_BTN[button])
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, button, istouch, presses) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- touch
|
||||
--
|
||||
-- A finger on open screen -- not on the overlay's d-pad or buttons --
|
||||
-- becomes the look drag. One finger owns the look at a time; every
|
||||
-- other touch flows to TouchControls untouched, so a thumb can drag the
|
||||
-- view while the other walks the d-pad. That d-pad finger is also read
|
||||
-- back ANALOG here: TouchControls quantises it to four directions for
|
||||
-- the grid game, but the deflection it quantised is exactly the move
|
||||
-- vector a free walk wants.
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local function dpadVector(x, y)
|
||||
local ok, v = pcall(function()
|
||||
local L = TouchControls:layout()
|
||||
local dz = L.dpad
|
||||
local half = dz.w * 0.65
|
||||
return { x = math.max(-1, math.min(1, (x - dz.cx) / half)),
|
||||
y = math.max(-1, math.min(1, (y - dz.cy) / half)) }
|
||||
end)
|
||||
return ok and v or nil
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchpressed
|
||||
function Game:touchpressed(id, x, y)
|
||||
if FirstPerson.driving() then
|
||||
local onControl = nil
|
||||
pcall(function() onControl = TouchControls:hitTest(x, y) end)
|
||||
if not onControl and not lookTouch then
|
||||
lookTouch = { id = id, x = x, y = y }
|
||||
return
|
||||
end
|
||||
inner(self, id, x, y)
|
||||
if onControl == "dpad" and TouchControls.dpadTouch == id then
|
||||
touchMove = dpadVector(x, y)
|
||||
end
|
||||
return
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = Game.touchmoved
|
||||
function Game:touchmoved(id, x, y)
|
||||
if lookTouch and lookTouch.id == id then
|
||||
local w = 1280
|
||||
pcall(function() w = love.graphics.getWidth() end)
|
||||
local per = FirstPerson.TOUCH_TURN / math.max(320, w)
|
||||
if FirstPerson.driving() then
|
||||
-- negated yaw for the same reason as the mouse (see update):
|
||||
-- drag right, look right, the mobile-shooter convention
|
||||
FirstPerson.lookBy(-(x - lookTouch.x) * per,
|
||||
(y - lookTouch.y) * per)
|
||||
end
|
||||
lookTouch.x, lookTouch.y = x, y
|
||||
return
|
||||
end
|
||||
if touchMove and TouchControls.dpadTouch == id then
|
||||
touchMove = dpadVector(x, y) or touchMove
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = Game.touchreleased
|
||||
function Game:touchreleased(id, x, y)
|
||||
if lookTouch and lookTouch.id == id then
|
||||
lookTouch = nil
|
||||
return
|
||||
end
|
||||
if TouchControls.dpadTouch == id then touchMove = nil end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- a reset that drops held input state drops ours with it
|
||||
do
|
||||
local inner = Game.focus
|
||||
function Game:focus(f)
|
||||
lookTouch, touchMove = nil, nil
|
||||
stick.x, stick.y = 0, 0
|
||||
mouseDX, mouseDY = 0, 0
|
||||
return inner(self, f)
|
||||
end
|
||||
end
|
||||
-- a disconnected controller cannot send the centering event for whatever
|
||||
-- its stick last held -- the engine drops all input state here, and the
|
||||
-- look rate (plus the raw axes' earned trust) goes with it
|
||||
do
|
||||
local inner = Game.joystickremoved
|
||||
function Game:joystickremoved(joystick)
|
||||
stick.x, stick.y = 0, 0
|
||||
rawCentred[3], rawCentred[4] = nil, nil
|
||||
return inner(self, joystick)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return FirstPerson
|
||||
@@ -0,0 +1,345 @@
|
||||
-- Voxel world mode: free movement for the first-person rung.
|
||||
--
|
||||
-- The engine walks a grid: sixteen frames per cell, four directions,
|
||||
-- input locked mid-step. Inside a first-person camera that gait reads as
|
||||
-- riding a rail, so while 1ST drives, this module replaces the WALK and
|
||||
-- nothing else: the player's position becomes continuous, steered by the
|
||||
-- camera's own yaw -- push forward and you go where you look, at any
|
||||
-- angle, sliding along whatever you graze.
|
||||
--
|
||||
-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
|
||||
-- about cells -- what blocks, what warps, what rustles, what bites -- and
|
||||
-- this module keeps the player's logical cell synced to wherever the free
|
||||
-- walk stands, then reuses the engine's own machinery for every one of
|
||||
-- those questions:
|
||||
--
|
||||
-- passability the same isWalkableCell / water-while-surfing /
|
||||
-- tile-pair / entity-occupancy verdicts Collision
|
||||
-- hands the grid walker, asked per cell the player's
|
||||
-- body overlaps.
|
||||
--
|
||||
-- cell arrival OverworldState:onStepComplete, the same landing
|
||||
-- pipeline a grid step runs -- warps, spinners, gates,
|
||||
-- forced currents, poison, repel, encounters, the
|
||||
-- step counters -- fired once per cell crossed, which
|
||||
-- is exactly the rate a grid walk fires it.
|
||||
--
|
||||
-- the special pushes walking off the map edge, into a ledge, or into
|
||||
-- a boulder hands the quantised direction straight to
|
||||
-- checkEdgeExit / checkLedgeHop / checkBoulderPush,
|
||||
-- the engine's own handlers, which validate and stage
|
||||
-- everything themselves (connections, the hop arc,
|
||||
-- the two-push arm). While any of those animates a
|
||||
-- scripted grid move, this module stands aside and
|
||||
-- adopts the result.
|
||||
--
|
||||
-- Nothing here writes save state, rolls encounters, or decides what a
|
||||
-- warp does -- it moves a point, keeps the cell honest, and lets the
|
||||
-- engine be the engine. Stepping off the rung snaps the point to its
|
||||
-- cell and hands the walk back to the grid, and with the rung off this
|
||||
-- module costs one gate check per frame.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
|
||||
local FreeMove = {}
|
||||
|
||||
-- The body: a circle in the ground plane. Small enough to walk every
|
||||
-- one-cell corridor the grid game has (half a cell is 8), big enough to
|
||||
-- keep the eye's near plane out of wall faces when sliding along them.
|
||||
FreeMove.RADIUS = 5.5
|
||||
|
||||
-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16
|
||||
-- frames per 16px cell on foot, 8 on the bike), so distance covered per
|
||||
-- second is unchanged and the encounter rate per tile crossed stays the
|
||||
-- game's own.
|
||||
FreeMove.WALK = 1.0
|
||||
FreeMove.BIKE = 2.0
|
||||
|
||||
local EPS = 0.01
|
||||
|
||||
-- the free position (player centre, world px) and the px/py we last wrote
|
||||
-- -- if they differ from the player's, something else (a warp, a script)
|
||||
-- moved them, and the free walk adopts rather than fights
|
||||
local pos = nil
|
||||
local lastPx, lastPy = nil, nil
|
||||
|
||||
local function adopt(p)
|
||||
pos = { x = p.px + 8, z = p.py + 8 }
|
||||
lastPx, lastPy = p.px, p.py
|
||||
end
|
||||
|
||||
function FreeMove.drop()
|
||||
pos = nil
|
||||
end
|
||||
|
||||
-- named for the suite: the module's live position, nil while dropped
|
||||
function FreeMove._pos()
|
||||
return pos
|
||||
end
|
||||
|
||||
-- ------- the per-cell verdict
|
||||
--
|
||||
-- The same questions Collision.canMove asks for a grid step, asked of one
|
||||
-- cell from the player's current standing. The player's OWN cell never
|
||||
-- blocks -- the body must always be free to leave wherever it stands
|
||||
-- (a warp mat, the water it is surfing, a cell an NPC just stepped
|
||||
-- against).
|
||||
|
||||
local function pairBlocked(map, surfing, sx, sy, tx, ty)
|
||||
local Game = require("src.core.Game")
|
||||
local tp = Game.data and Game.data.field and Game.data.field.tilePairs
|
||||
if not tp then return false end
|
||||
local list = surfing and tp.water or tp.land
|
||||
if not list or #list == 0 then return false end
|
||||
local tileset = map.def.tileset
|
||||
local a = map:cellTile(sx, sy)
|
||||
local b = map:cellTile(tx, ty)
|
||||
for _, p in ipairs(list) do
|
||||
if p.tileset == tileset
|
||||
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Why (cx, cy) refuses the player's body, or nil when it may enter:
|
||||
-- "bounds" | "tile" | "entity", the grid verdict's own names.
|
||||
local function blockedCell(state, p, cx, cy)
|
||||
if cx == p.cellX and cy == p.cellY then return nil end
|
||||
local map = state.map
|
||||
if not map:inBounds(cx, cy) then return "bounds" end
|
||||
if not map:isWalkableCell(cx, cy) then
|
||||
if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end
|
||||
end
|
||||
if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then
|
||||
return "tile"
|
||||
end
|
||||
local Collision = require("src.world.Collision")
|
||||
if Collision.occupied(state.entities, cx, cy, p) then return "entity" end
|
||||
return nil
|
||||
end
|
||||
|
||||
FreeMove._blockedCell = blockedCell -- named for the suite
|
||||
|
||||
-- ------- the slide
|
||||
--
|
||||
-- One axis at a time, clamped at the first refusing cell's face: the
|
||||
-- classic axis-separated walk, which is where wall-sliding comes from --
|
||||
-- the blocked axis stops and the free one keeps going. Returns the
|
||||
-- refusal ("bounds"/"tile"/"entity") when this axis was clamped.
|
||||
|
||||
local function slideX(state, p, dx)
|
||||
if dx == 0 then return nil end
|
||||
local r = FreeMove.RADIUS
|
||||
local nx = pos.x + dx
|
||||
local z0 = math.floor((pos.z - r + EPS) / 16)
|
||||
local z1 = math.floor((pos.z + r - EPS) / 16)
|
||||
local hit = nil
|
||||
local edge = dx > 0 and math.floor((nx + r) / 16)
|
||||
or math.floor((nx - r) / 16)
|
||||
for zc = z0, z1 do
|
||||
hit = blockedCell(state, p, edge, zc)
|
||||
if hit then break end
|
||||
end
|
||||
if hit then
|
||||
if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS)
|
||||
else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end
|
||||
end
|
||||
pos.x = nx
|
||||
return hit
|
||||
end
|
||||
|
||||
local function slideZ(state, p, dz)
|
||||
if dz == 0 then return nil end
|
||||
local r = FreeMove.RADIUS
|
||||
local nz = pos.z + dz
|
||||
local x0 = math.floor((pos.x - r + EPS) / 16)
|
||||
local x1 = math.floor((pos.x + r - EPS) / 16)
|
||||
local hit = nil
|
||||
local edge = dz > 0 and math.floor((nz + r) / 16)
|
||||
or math.floor((nz - r) / 16)
|
||||
for xc = x0, x1 do
|
||||
hit = blockedCell(state, p, xc, edge)
|
||||
if hit then break end
|
||||
end
|
||||
if hit then
|
||||
if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS)
|
||||
else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end
|
||||
end
|
||||
pos.z = nz
|
||||
return hit
|
||||
end
|
||||
|
||||
-- ------- the blocked push
|
||||
--
|
||||
-- The grid game's blocked step is where half its verbs live: the map-edge
|
||||
-- crossing, the ledge hop, the boulder shove, the route-gate warp fired
|
||||
-- by collision, and the honest bonk. Hand the engine the quantised
|
||||
-- direction and let its own handlers decide -- each one validates itself
|
||||
-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds),
|
||||
-- so calling them on every firm push is safe. Returns true when one of
|
||||
-- them took the frame over.
|
||||
local function pushSpecials(state, dir, why)
|
||||
local p = state.player
|
||||
p.facing = dir -- the handlers read the push off the facing
|
||||
if why == "bounds" and state:checkEdgeExit(dir) then return true end
|
||||
if state:checkLedgeHop(dir) then return true end
|
||||
if state:checkBoulderPush(dir) then return true end
|
||||
if why ~= "entity" and state:canCollisionWarp() then
|
||||
local Game = require("src.core.Game")
|
||||
local Warp = require("src.world.Warp")
|
||||
local w = Warp.onCollision(state.map, Game.data.field.warpCarpets,
|
||||
p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
state:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if why ~= "entity" then
|
||||
if (state.bumpCooldown or 0) <= 0 then
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
state.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Runs in place of OverworldState:handleInput while first person drives
|
||||
-- (see install below), which means it inherits every gate the grid walk
|
||||
-- has: never during scripted moves, transitions, or with anything above
|
||||
-- the overworld on the stack.
|
||||
|
||||
function FreeMove.tick(state)
|
||||
local p = state.player
|
||||
|
||||
-- a grid move is animating -- a ledge hop, a spinner slide, a scripted
|
||||
-- walk -- or a cutscene owns the player: stand aside, adopt the result
|
||||
if p.moving or p.inputLocked then
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local input = Game.input
|
||||
|
||||
-- the head is the facing: what A talks to, what the sun's card shows,
|
||||
-- which way a bonk points
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
|
||||
if input:wasPressed("a") then
|
||||
state:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
require("src.ui.Screens").push(Game, "StartMenu")
|
||||
return
|
||||
end
|
||||
|
||||
local mx, mz = FirstPerson.moveVector()
|
||||
local wx, wz = FirstPerson.moveWorld(mx, mz)
|
||||
|
||||
-- Cycling Road's downhill pull, the free-walk restatement of the grid
|
||||
-- path's simulated PAD_DOWN: south drift with nothing held, braked by
|
||||
-- holding A or B exactly as the Route 17 sign promises
|
||||
local moving = (mx ~= 0 or mz ~= 0)
|
||||
if not moving and Game.save and Game.save.onBike then
|
||||
local fm = Game.data.field.forcedMovement
|
||||
local braking = input:isDown("a") or input:isDown("b")
|
||||
if fm and not braking then
|
||||
for _, m in ipairs(fm.slopeMaps or {}) do
|
||||
if m == state.map.id then
|
||||
wx, wz, moving = 0, 1, true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not moving then return end
|
||||
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
or FreeMove.WALK
|
||||
local dx, dz = wx * speed, wz * speed
|
||||
|
||||
local hitX = slideX(state, p, dx)
|
||||
local hitZ = slideZ(state, p, dz)
|
||||
|
||||
-- the walk cycle: the wall-bonk clock animates the legs of a player the
|
||||
-- grid thinks is standing still, refreshed while the free walk covers
|
||||
-- ground (Player:update ticks animClock off it; walkPhase reads it)
|
||||
p.bumpFrames = 2
|
||||
|
||||
p.px, p.py = pos.x - 8, pos.z - 8
|
||||
lastPx, lastPy = p.px, p.py
|
||||
|
||||
-- the cell the body stands in; crossing into a new one IS a step
|
||||
local ncx = math.floor(pos.x / 16)
|
||||
local ncy = math.floor(pos.z / 16)
|
||||
if ncx ~= p.cellX or ncy ~= p.cellY then
|
||||
p.cellX, p.cellY = ncx, ncy
|
||||
state:onStepComplete()
|
||||
-- a warp or a battle may have moved the world out from under the
|
||||
-- walk; the adopt check on the next tick picks the pieces up
|
||||
return
|
||||
end
|
||||
|
||||
-- a firm push into something that refused: the engine's own blocked-step
|
||||
-- verbs, aimed the way the push leans
|
||||
local hit, dir
|
||||
if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then
|
||||
hit, dir = hitX, (dx > 0 and "right" or "left")
|
||||
elseif hitZ then
|
||||
hit, dir = hitZ, (dz > 0 and "down" or "up")
|
||||
end
|
||||
if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then
|
||||
if pushSpecials(state, dir, hit) then
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
-- the push handlers may have turned the facing; the head still rules
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the seam
|
||||
--
|
||||
-- OverworldState:handleInput is the one choke point where the grid walk
|
||||
-- reads the pad -- the same seam the engine's own Cycling Road pull and
|
||||
-- collision warps live behind -- so replacing the walk means wrapping it
|
||||
-- and nothing else. Every gate ABOVE the call (scripted moves, trainer
|
||||
-- engagement, transitions, anything on the stack) still applies to the
|
||||
-- free walk, because the wrap sits below them all.
|
||||
function FreeMove.install()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if OverworldState.dramaticShapeFreeMoveHook then return end
|
||||
local inner = OverworldState.handleInput
|
||||
|
||||
function OverworldState:handleInput()
|
||||
if not FirstPerson.driving() then
|
||||
if pos then
|
||||
-- stepping off the rung: back onto the grid, on the cell the
|
||||
-- free walk stood in
|
||||
local p = self.player
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
FreeMove.drop()
|
||||
end
|
||||
return inner(self)
|
||||
end
|
||||
return FreeMove.tick(self)
|
||||
end
|
||||
|
||||
OverworldState.dramaticShapeFreeMoveHook = true
|
||||
end
|
||||
|
||||
return FreeMove
|
||||
+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
|
||||
|
||||
+1115
-162
File diff suppressed because it is too large
Load Diff
+202
-40
@@ -71,6 +71,21 @@ local FALLBACK_HEIGHTS = {
|
||||
-- body builds from the bark rows and the drawn ellipse projects onto
|
||||
-- the hull's round top
|
||||
stump = 16,
|
||||
-- the same hull cut at both ends, hollowed and tapered: an OPEN bin
|
||||
-- standing on a floor (the Vermilion Gym trash cans). The drawn mouth
|
||||
-- ellipse projects onto the round top and down the well, the drawn base
|
||||
-- ellipse is ground contact rather than body, and the plan narrows toward
|
||||
-- the floor. Height is AUTHORED (the profile's can_height, which this
|
||||
-- pin must be kept equal to so anything riding a can lands on its rim) --
|
||||
-- the drawing's own straight run is only a couple of rows, because a GB
|
||||
-- cell spends most of itself on the opening
|
||||
can = 9,
|
||||
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
|
||||
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
|
||||
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
|
||||
-- the object's height, not its depth. BOTH cells take the class; the
|
||||
-- group build anchors on the north one (Structures.buildCylinders)
|
||||
planter = 32,
|
||||
billboard = 16,
|
||||
signpost = 16,
|
||||
post = 16,
|
||||
@@ -83,10 +98,18 @@ 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,
|
||||
cutout = 16,
|
||||
-- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like
|
||||
-- every other cutout pool -- what differs is the thickness (see
|
||||
-- Structures' PINNED_DEPTH)
|
||||
bike = 16,
|
||||
console = 16,
|
||||
relief = 3,
|
||||
bookcase = 32,
|
||||
@@ -123,6 +146,8 @@ local ART = {
|
||||
cylinder = "cylinder",
|
||||
canopy = "canopy",
|
||||
stump = "cylinder",
|
||||
can = "cylinder",
|
||||
planter = "planter",
|
||||
billboard = "billboard",
|
||||
-- signposts share the billboard treatment but as their own pool at a
|
||||
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
|
||||
@@ -145,6 +170,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
|
||||
@@ -158,6 +186,13 @@ local ART = {
|
||||
desk = "upright",
|
||||
prop = "billboard",
|
||||
cutout = "billboard",
|
||||
-- a bicycle is a LINE drawing seen side-on, and its negative space --
|
||||
-- the air inside the frame, between the wheel and the fork -- is what
|
||||
-- makes it read as a bicycle at all. Its own pool at two voxels: any
|
||||
-- thicker and the side faces of neighbouring strokes close those gaps
|
||||
-- from every angle but dead-on, and six of them in a showroom come out
|
||||
-- as one dark lump (which is what the 5px `prop` pool gave)
|
||||
bike = "billboard",
|
||||
-- a machine standing on furniture: the billboard treatment with
|
||||
-- body, plus the one-object contract `cutout` has -- the drawing is
|
||||
-- ringed by the furniture it sits on, and those edges must not be
|
||||
@@ -176,6 +211,7 @@ local ART = {
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
local cache = {} -- tileset id -> resolved shape list
|
||||
local figCache = {} -- tileset id -> parsed figure masks, or false
|
||||
local mntCache = {} -- tileset id -> parsed mounted masks, or false
|
||||
local bgCache = {} -- tileset id -> prop background shades, or false
|
||||
|
||||
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
|
||||
@@ -295,6 +331,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)
|
||||
@@ -405,68 +459,158 @@ end
|
||||
-- pixel by pixel (see data/voxel_heights.lua):
|
||||
--
|
||||
-- figures = { { w = <tiles across>,
|
||||
-- depth = <voxels of body; ABSENT for a person>,
|
||||
-- thin = { rows = <top rows>, depth = <voxels> },
|
||||
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
-- under = { ...w*h ids: what each tile wears once the
|
||||
-- figure is lifted off it... },
|
||||
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
|
||||
-- figure... } } }
|
||||
--
|
||||
-- No class: a figure is always a flat sprite card, drawn the way
|
||||
-- SpriteBillboards draws a character (see Structures.buildFigures).
|
||||
-- No class -- what the entry carries instead is a `depth`, or does not:
|
||||
--
|
||||
-- WITHOUT one it is a flat sprite card, drawn the way SpriteBillboards
|
||||
-- draws a character. That is the right reading for a PERSON: a Gen 1
|
||||
-- figure is a face-on 2D icon, and extruding one reconstructs a body
|
||||
-- nobody drew (see Structures.buildFigures).
|
||||
-- WITH one it is an OBJECT and gets the standee treatment every other
|
||||
-- solid here gets -- a per-pixel slab in world space, standing on the
|
||||
-- same furniture the card would have stood on. The Marts' cash
|
||||
-- register is the case: a machine on a counter is a box, not an icon.
|
||||
--
|
||||
-- Two fields say which parts of such a drawing are NOT the extrusion,
|
||||
-- because a solid drawn in one 16x16 GB cell still packs more than one
|
||||
-- facing:
|
||||
--
|
||||
-- `thin` caps the thickness over the mask's top rows, for the part of
|
||||
-- the drawing that is not the machine (the register's receipt curl).
|
||||
-- `flat` names a rect of the mask that is a TOP-VIEW surface rather
|
||||
-- than a face -- the register's keypad, whose keys lie ON its deck.
|
||||
-- The rect lays horizontal one voxel proud of whatever the extrusion
|
||||
-- leaves below it, at the elevation its BOTTOM row would have had,
|
||||
-- with drawn row = depth row 1:1 (the mapping the lab tabletop is
|
||||
-- drawn with). So a drawing whose front elevation is an L reads as
|
||||
-- one: body up the side and along the base, keys lying in the notch.
|
||||
--
|
||||
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
|
||||
-- Structures can read it as a bitmap without re-parsing per position.
|
||||
-- A malformed entry is dropped rather than half-applied -- a typo in a
|
||||
-- mask should leave the couch alone, not carve a hole in it.
|
||||
--
|
||||
-- `mounted` (below) carries the same four fields, so the parse is shared,
|
||||
-- and so are the optional ones that give an authored mask a BODY: `depth`,
|
||||
-- `thin` and `flat` above. `depth` is left nil when unstated, because
|
||||
-- absence is meaningful on a figure: no depth means the flat sprite card a
|
||||
-- person is drawn as.
|
||||
local function authoredMasks(list)
|
||||
local out = {}
|
||||
if type(list) ~= "table" then return out end
|
||||
for _, f in ipairs(list) do
|
||||
local ok = type(f) == "table" and type(f.w) == "number"
|
||||
and type(f.tiles) == "table" and type(f.under) == "table"
|
||||
and type(f.pixels) == "table"
|
||||
local w = ok and math.floor(f.w) or 0
|
||||
local h = (w >= 1) and (#f.tiles / w) or 0
|
||||
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
|
||||
and #f.under == #f.tiles and #f.pixels == h * 8
|
||||
if ok then
|
||||
for i = 1, h * 8 do
|
||||
local row = f.pixels[i]
|
||||
if type(row) ~= "string" or #row ~= w * 8 then
|
||||
ok = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
local mask, n = {}, 0
|
||||
for ly = 0, h * 8 - 1 do
|
||||
local row = f.pixels[ly + 1]
|
||||
for lx = 0, w * 8 - 1 do
|
||||
if row:sub(lx + 1, lx + 1) ~= "." then
|
||||
mask[ly * (w * 8) + lx] = true
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
local depth = tonumber(f.depth)
|
||||
local thin = nil
|
||||
if type(f.thin) == "table" and tonumber(f.thin.rows)
|
||||
and tonumber(f.thin.depth) then
|
||||
thin = { rows = math.floor(tonumber(f.thin.rows)),
|
||||
depth = math.floor(tonumber(f.thin.depth)) }
|
||||
end
|
||||
local flat = nil
|
||||
if type(f.flat) == "table" and type(f.flat.x) == "table"
|
||||
and type(f.flat.rows) == "table" then
|
||||
flat = { x0 = math.floor(f.flat.x[1]), x1 = math.floor(f.flat.x[2]),
|
||||
r0 = math.floor(f.flat.rows[1]),
|
||||
r1 = math.floor(f.flat.rows[2]) }
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under,
|
||||
depth = depth and math.floor(depth) or nil,
|
||||
thin = thin, flat = flat }
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function TileShape.figures(tilesetId)
|
||||
local hit = figCache[tilesetId]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local list = entry and entry.figures
|
||||
local out = {}
|
||||
if type(list) == "table" then
|
||||
for _, f in ipairs(list) do
|
||||
local ok = type(f) == "table" and type(f.w) == "number"
|
||||
and type(f.tiles) == "table" and type(f.under) == "table"
|
||||
and type(f.pixels) == "table"
|
||||
local w = ok and math.floor(f.w) or 0
|
||||
local h = (w >= 1) and (#f.tiles / w) or 0
|
||||
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
|
||||
and #f.under == #f.tiles and #f.pixels == h * 8
|
||||
if ok then
|
||||
for i = 1, h * 8 do
|
||||
local row = f.pixels[i]
|
||||
if type(row) ~= "string" or #row ~= w * 8 then
|
||||
ok = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
local mask, n = {}, 0
|
||||
for ly = 0, h * 8 - 1 do
|
||||
local row = f.pixels[ly + 1]
|
||||
for lx = 0, w * 8 - 1 do
|
||||
if row:sub(lx + 1, lx + 1) ~= "." then
|
||||
mask[ly * (w * 8) + lx] = true
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local out = authoredMasks(entry and entry.figures)
|
||||
|
||||
figCache[tilesetId] = (#out > 0) and out or false
|
||||
return figCache[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- Hand-authored MOUNTED objects for one tileset: a thing drawn INTO the
|
||||
-- wall band it hangs on, cut out by an explicit pixel mask and stood
|
||||
-- proud of the wall's face.
|
||||
--
|
||||
-- Same authoring problem as `figures` and the same answer -- a class pin
|
||||
-- resolves a whole 8x8 tile, and the detector cannot segment a drawing
|
||||
-- that has no background margin to flood from. The Bike Shop's two wall
|
||||
-- bicycles are the case: the shop's striped wall panel runs BEHIND them,
|
||||
-- and its #555 stripes are a flood boundary, so a silhouette flood comes
|
||||
-- back with the stripes attached to the bike.
|
||||
--
|
||||
-- Two things differ from a figure, and both follow from the object being
|
||||
-- an object rather than a character:
|
||||
--
|
||||
-- it keeps its DRAWN ELEVATION. A figure stands on its own feet; a
|
||||
-- mounted thing sits where the wall band draws it, so a bicycle hung
|
||||
-- clear of the floor stays hung.
|
||||
-- it has THICKNESS (`depth`, default 2), and it is built in world
|
||||
-- space as a per-pixel slab jutting south of the band -- not as a
|
||||
-- camera-facing sprite card. A bicycle drawn side-on is a plane
|
||||
-- parallel to the wall, not a face-on icon.
|
||||
--
|
||||
-- mounted = { { w = <tiles across>,
|
||||
-- depth = <voxels it juts into the room>,
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
-- under = { ...w*h ids: what each tile wears once the
|
||||
-- object is lifted off it (the plain panel)... },
|
||||
-- pixels = { ...h*8 strings of w*8 chars, "." = wall... } } }
|
||||
function TileShape.mounted(tilesetId)
|
||||
local hit = mntCache[tilesetId]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local out = authoredMasks(entry and entry.mounted)
|
||||
|
||||
mntCache[tilesetId] = (#out > 0) and out or false
|
||||
return mntCache[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
|
||||
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
|
||||
--
|
||||
@@ -539,12 +683,30 @@ function TileShape.bookcaseBackfill(tilesetId)
|
||||
return mode == "above" and mode or nil
|
||||
end
|
||||
|
||||
--- Does this tileset's `bookcase` run carry the measured pane RELIEF on
|
||||
--- its front (a tileset entry's bookcase_relief)? Default yes: the class
|
||||
--- almost always collapses a shelf, a rack or a display case, and every
|
||||
--- one of those seals its contents behind a frame that should stand proud
|
||||
--- of them.
|
||||
---
|
||||
--- A tileset says `bookcase_relief = false` when it borrows the collapse
|
||||
--- for something that is NOT a shelf -- the League's gate walls and
|
||||
--- pilasters, Bill's transporter drums -- where the drawing's light
|
||||
--- regions are the masonry and the barrel, not panes, and sinking them
|
||||
--- carves the surface instead of describing it.
|
||||
function TileShape.bookcaseRelief(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
return not (entry and entry.bookcase_relief == false)
|
||||
end
|
||||
|
||||
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
|
||||
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||
function TileShape.invalidate()
|
||||
spec = nil
|
||||
cache = {}
|
||||
figCache = {}
|
||||
mntCache = {}
|
||||
bgCache = {}
|
||||
end
|
||||
|
||||
|
||||
+9
-5
@@ -421,7 +421,8 @@ end
|
||||
-- ---------------------------------------------------------------- camera --
|
||||
|
||||
-- An explicit camera, replacing the orbit below for as long as it is set:
|
||||
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
|
||||
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil,
|
||||
-- up = {x,y,z} or nil }.
|
||||
--
|
||||
-- The orbit is the free-roam camera and it is described entirely by ONE
|
||||
-- number, the pitch, because that is all a camera following the player over
|
||||
@@ -496,9 +497,12 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
||||
-- bypass LOVE's transform_projection and canvas coordinates run Y down
|
||||
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
||||
-- world up, so the horizon stays level -- a placed camera that rolled
|
||||
-- with its own pitch would tip the whole arena
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
|
||||
-- world up by default, so the horizon stays level -- a placed camera
|
||||
-- that rolled with its own pitch would tip the whole arena. A caller
|
||||
-- may hand its own up: the first-person BLEND does, because its far
|
||||
-- end is the orbit, whose up leans with the pitch -- world up at the
|
||||
-- orbit's steep end degenerates against a straight-down view.
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 }))
|
||||
end
|
||||
|
||||
local a = Voxel.angle
|
||||
@@ -715,7 +719,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" })
|
||||
|
||||
+144
-15
@@ -24,6 +24,7 @@ local Sky = V.require("Sky")
|
||||
local Water = V.require("Water")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -214,6 +215,22 @@ local function frameFor(def, facing, phase, flip)
|
||||
return frame, mirror
|
||||
end
|
||||
|
||||
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
|
||||
-- looks from the south", which is where the orbit always stands; a
|
||||
-- first-person eye stands anywhere, so deep enough into the blend the
|
||||
-- facing is remapped to how the pose looks from THERE -- walk behind an
|
||||
-- NPC and their card wears the back sprite. Used by the camera draw and
|
||||
-- the sun pass BOTH: the card the sun stored and the transform a lit card
|
||||
-- reads its own shadowing with must describe the same frame, or the
|
||||
-- mirror-flip half of the pair asks the map about texels the sun filed
|
||||
-- under the other cheek.
|
||||
local function viewFacing(p)
|
||||
if FirstPerson.cardBlend() > 0.5 then
|
||||
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return p.facing
|
||||
end
|
||||
|
||||
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
|
||||
-- a decal: its current sprite frame as a single quad, flattened onto the
|
||||
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
|
||||
@@ -236,10 +253,22 @@ end
|
||||
-- Shared by the solid draw and the silhouette below, so the two can never
|
||||
-- drift apart -- a silhouette standing anywhere but exactly behind the
|
||||
-- figure would read as a second character.
|
||||
--
|
||||
-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed
|
||||
-- about its feet to face the eye (cylindrical billboarding). A south-facing
|
||||
-- card is invisible edge-on to an eye standing east of it, which no orbit
|
||||
-- camera could ever do and a first-person one does constantly. The blend
|
||||
-- carries one pose into the other -- the lean eases out as the yaw eases in
|
||||
-- -- and cardBlend is zero for every camera that is not the first-person
|
||||
-- rig, the battle's placed shot included, so nothing else moves.
|
||||
local function billboardMatrix(px, py, y, mirror)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
|
||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
||||
local b = FirstPerson.cardBlend()
|
||||
local m = Mat4.translate(px + 8, y, py + 8)
|
||||
if b > 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||
end
|
||||
m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
||||
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
||||
end
|
||||
@@ -258,10 +287,24 @@ end
|
||||
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
|
||||
-- around him. No cell centring: unlike a character he is not standing on a
|
||||
-- cell, he is standing where he was drawn, which may straddle two.
|
||||
--
|
||||
-- First person turns him at the eye like the walkers (see billboardMatrix)
|
||||
-- -- about his own middle, because unlike a character card his local space
|
||||
-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about
|
||||
-- his edge would swing him off his seat. The width rode in on the record
|
||||
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
||||
local function figureMatrix(f, offX, offZ)
|
||||
local Voxel = V.require("VoxelState")
|
||||
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
|
||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
||||
local b = FirstPerson.cardBlend()
|
||||
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
|
||||
local m = Mat4.translate(wx, f.y, wz)
|
||||
if b > 0 and f.w and f.w > 0 then
|
||||
local half = f.w / 2
|
||||
m = Mat4.mul(m, Mat4.translate(half, 0, 0))
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
|
||||
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
|
||||
end
|
||||
return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
end
|
||||
|
||||
-- What the sun sees: the same card UNLEANED and flattened, exactly as
|
||||
@@ -333,7 +376,7 @@ VoxelScene.drawEntity = drawEntity
|
||||
-- mesh for it.
|
||||
local function drawGhost(p)
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if not mesh then return end
|
||||
local tex = p.sprite:resolveImage()
|
||||
@@ -462,7 +505,13 @@ local function posesOf(state, spriteColors)
|
||||
gh = groundAt(state.map, e.cellX, e.cellY),
|
||||
lift = e.py - vy, colors = colors,
|
||||
}
|
||||
if e == state.player then me = posed[#posed] end
|
||||
if e == state.player then
|
||||
me = posed[#posed]
|
||||
-- marked so the camera draw can leave the card out in first
|
||||
-- person, where it would fill the lens from inside; the SUN pass
|
||||
-- reads the same list and deliberately does not check the mark
|
||||
me.isPlayer = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return posed, me
|
||||
@@ -529,9 +578,18 @@ local function drawCast(state, posed, atlasFor)
|
||||
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||
-- character genuinely behind a building is far deeper and loses the
|
||||
-- test, so buildings and trees really occlude.
|
||||
--
|
||||
-- In first person two of them change: the player's own card is left out
|
||||
-- (the eye is standing in it), and every other card wears the frame its
|
||||
-- pose SHOWS this eye (viewFacing) rather than the one it shows the
|
||||
-- south. Both run through here, so the water's reflection copy -- drawn
|
||||
-- by this same function -- agrees with the frame to the pixel.
|
||||
local hideMe = FirstPerson.hidePlayer()
|
||||
for _, p in ipairs(posed) do
|
||||
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
p.colors, p.lift)
|
||||
if not (p.isPlayer and hideMe) then
|
||||
drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
||||
p.colors, p.lift)
|
||||
end
|
||||
end
|
||||
-- back on for everything textured from the atlas again -- figures, grass
|
||||
-- and flowers all sample it, where the mask's coordinates are honest
|
||||
@@ -583,8 +641,48 @@ end
|
||||
-- The overworld's alone: the staged battle draws its water plain, always --
|
||||
-- its placed camera reads this pass wrong, and a stage set wants painted
|
||||
-- water anyway (see BattleScene, where the choice is argued).
|
||||
-- ------- and why the flat draw happens FIRST while the world is curved
|
||||
--
|
||||
-- The reflective pass writes no depth -- it cannot, the depth canvas is
|
||||
-- detached for the length of it so the shader can READ it -- and it does its
|
||||
-- own depth test against that texture instead. That test asks whether
|
||||
-- something opaque is in front, and it answers correctly for every case but
|
||||
-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so
|
||||
-- no lake can hide another, and the pass simply paints them in mesh order.
|
||||
--
|
||||
-- On a flat world that never matters: every surface lies in the one plane
|
||||
-- at its own recessed height, and a farther sheet always lands farther down
|
||||
-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the
|
||||
-- square of its distance, so the far side of the map swings down and back
|
||||
-- up into the near field of view -- and a sheet of sea a hundred and fifty
|
||||
-- tiles away, drawn later in the same mesh, paints straight over the pond
|
||||
-- at the player's feet. Not a reflection of the far shore: the far shore
|
||||
-- itself, rasterised on top of the water in front of you.
|
||||
--
|
||||
-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the
|
||||
-- ordinary scene shader with depth writes on, and the reflective pass draws
|
||||
-- over the top of what survived: the depth buffer now holds the water
|
||||
-- surface, so the pass's own test throws the far sheet away, and the
|
||||
-- reflection COPY holds it too, so a ray grazing another part of the lake
|
||||
-- reads water rather than the void behind it.
|
||||
--
|
||||
-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY,
|
||||
-- and it stays off -- the reflective pass tests only against terrain, as it
|
||||
-- always did. Painting the surface into the depth texture turns the pass's
|
||||
-- test into a comparison of the surface against ITSELF, which asks the two
|
||||
-- rasterisations to agree to within interpolation error -- and on mobile
|
||||
-- GPUs they don't reliably (that fight is what put the Android port back on
|
||||
-- flat water). Confined to the curve there is no regression to reach: the
|
||||
-- flat world never had the far-shore bug in the first place.
|
||||
function VoxelScene.drawWater(draws, cast)
|
||||
local plain = true
|
||||
-- prepass only under the bend; see the header
|
||||
local curved = (Voxel3D.curveK or 0) > 0
|
||||
if curved then
|
||||
for _, d in ipairs(draws) do
|
||||
Voxel3D.draw(d[1], d[2], d[3])
|
||||
end
|
||||
end
|
||||
local plain = not curved
|
||||
if Water.enabled() and Voxel3D.depthReadable() then
|
||||
local mirror, depth = Voxel3D.beginWater(cast)
|
||||
local w, h = Voxel3D.size()
|
||||
@@ -607,10 +705,12 @@ function VoxelScene.drawWater(draws, cast)
|
||||
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
|
||||
-- the shader and the depth mode BEFORE it can discover it cannot go on,
|
||||
-- so a frame that bails halfway through has to be put back together
|
||||
-- exactly like one that succeeded -- otherwise the plain draw below (and
|
||||
-- every pass after it) runs with no shader and no depth test.
|
||||
-- exactly like one that succeeded -- otherwise every pass after it runs
|
||||
-- with no shader and no depth test.
|
||||
Voxel3D.endWater()
|
||||
end
|
||||
-- the fallback flat draw -- unless the curve's prepass already put the
|
||||
-- same meshes down, in which case a bailed frame is already whole
|
||||
if plain then
|
||||
for _, d in ipairs(draws) do
|
||||
Voxel3D.draw(d[1], d[2], d[3])
|
||||
@@ -645,6 +745,10 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||
-- few times a minute rather than every frame.
|
||||
put(math.floor(ShadowMap.KX * 128))
|
||||
put(math.floor(ShadowMap.KZ * 128))
|
||||
-- and the first-person head: the box is fitted around wherever it looks
|
||||
-- and the sprite cards swap frames as it circles them, so a turn on the
|
||||
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
|
||||
put(FirstPerson.signature())
|
||||
put(tostring(terrain))
|
||||
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
||||
for _, p in ipairs(posed) do
|
||||
@@ -717,7 +821,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
end
|
||||
for _, p in ipairs(posed) do
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
-- viewFacing, exactly as the camera draw picks it (see viewFacing for
|
||||
-- why the two passes must agree): in first person the sun's card
|
||||
-- swaps frame as the eye circles, which costs a redraw the signature
|
||||
-- already charges for (FirstPerson.signature) and keeps a card from
|
||||
-- fringing against a mirror-flipped record of itself
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if mesh then
|
||||
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
||||
@@ -773,7 +882,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
end
|
||||
|
||||
local posed, me = posesOf(state, spriteColors)
|
||||
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
|
||||
|
||||
-- The first-person rig, built (or blended) for this frame and handed to
|
||||
-- Voxel3D BEFORE either pass runs: the sun's box is fitted around this
|
||||
-- camera, and every card matrix asks it which way to turn. With the
|
||||
-- blend fully out the call clears the placed camera and the orbit is
|
||||
-- exactly what it always was. The scene centre it returns walks from
|
||||
-- the orbit's view centre into the head, so the curve's focus and the
|
||||
-- depth reference follow the camera actually in charge.
|
||||
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
if fpRig then cx, cy = fpCx, fpCy end
|
||||
|
||||
-- The sun's box, pushed along the first-person look so it covers the
|
||||
-- ground THIS camera sees (a no-op at blend zero): the orbit's fit
|
||||
-- reaches far north and barely south, which is right for every rung
|
||||
-- but a head free to face south.
|
||||
local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh)
|
||||
castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor,
|
||||
water, nbWater)
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
@@ -796,7 +921,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
if not Voxel3D.shadowsActive() then
|
||||
Voxel3D.beginShadows()
|
||||
for _, p in ipairs(posed) do
|
||||
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
||||
p.lift)
|
||||
end
|
||||
Voxel3D.endShadows()
|
||||
@@ -842,7 +967,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- wrote it, so the silhouette would paint over the player at all times.
|
||||
-- Every character then draws on top as usual, which leaves the silhouette
|
||||
-- showing in exactly one situation: where the world hides them.
|
||||
if me then
|
||||
--
|
||||
-- Not in first person: the card it silhouettes is the one the camera is
|
||||
-- standing inside, and "the world is in front of the player" is every
|
||||
-- wall the player faces.
|
||||
if me and not FirstPerson.hidePlayer() then
|
||||
Voxel3D.beginGhost()
|
||||
drawGhost(me)
|
||||
Voxel3D.endGhost()
|
||||
|
||||
+25
-3
@@ -32,8 +32,18 @@ local Voxel = {}
|
||||
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
|
||||
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
|
||||
-- like, and two rungs may look the same while meaning different things.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
|
||||
--
|
||||
-- 1ST is the other rung that is more than an angle: the camera steps off its
|
||||
-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
|
||||
-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
|
||||
-- it hands over from -- because the tween in and out of first person starts
|
||||
-- from whatever the orbit shows, and the lowest rung is the one a dive into
|
||||
-- a head should start from. Everything angle-derived (the sky's fade, the
|
||||
-- billboard lean the blend eases away) reads that 75 while the first-person
|
||||
-- rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
|
||||
"1ST (EXPERIMENTAL)" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
@@ -43,6 +53,13 @@ function Voxel.isFull(level)
|
||||
return (level or Voxel.level) == Voxel.FULL_LEVEL
|
||||
end
|
||||
|
||||
-- the rung the first-person camera sits on, likewise
|
||||
Voxel.FP_LEVEL = 6
|
||||
|
||||
function Voxel.isFirstPerson(level)
|
||||
return (level or Voxel.level) == Voxel.FP_LEVEL
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
@@ -51,7 +68,12 @@ end
|
||||
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
|
||||
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
|
||||
-- row, which is where a preset that changes other rows belongs.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
|
||||
--
|
||||
-- 1ST is on the path: it changes the camera and only the camera, which is
|
||||
-- exactly what the key promises -- and the key is also the way back OUT of
|
||||
-- first person on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- menu is a trip.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
|
||||
+88
-11
@@ -381,9 +381,32 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra
|
||||
--
|
||||
-- The scene shader's own vertex path, plus the world position the geometry
|
||||
-- was actually DRAWN at -- after the world curve, because that is the space
|
||||
-- the depth buffer holds and therefore the space the march has to walk in.
|
||||
-- (The curve only ever moves Y, so a fragment's world XZ is the same on both
|
||||
-- sides of it and the ripple can be measured off this one too.)
|
||||
-- the surface the eye MEETS lives in: which wave column a screen pixel is
|
||||
-- looking at is a question about the geometry as drawn, and relief() answers
|
||||
-- it there. (The curve only ever moves Y, so a fragment's world XZ is the
|
||||
-- same on both sides of it and the ripple can be measured off this one too.)
|
||||
--
|
||||
-- WHAT IT REFLECTS is worked out on the other side of the bend, in the FLAT
|
||||
-- world, and this is the same rule the rest of the mode keeps: the curve
|
||||
-- tips the world away and the things standing on it do not lean with it (see
|
||||
-- WorldCurve -- buildings stay upright, shadows are resolved before the bend
|
||||
-- and ride along). A lake is one of those things. Reflect off the bowl the
|
||||
-- bend has made instead and the far half of a pond is a mirror tilted twenty
|
||||
-- degrees: it throws the ray past the vertical, where the sky ramp's own
|
||||
-- measure -- a screen row, through the frame's matrix -- swings from one end
|
||||
-- of the ramp to the other across a single column, and the pond comes out
|
||||
-- with hard-edged patches of the wrong sky stamped into it -- the overhead
|
||||
-- band and the horizon band abutting in the middle of a lake, which reads as
|
||||
-- something other than water showing through. The same tilt sends the
|
||||
-- screen-space march grazing along the bank instead of over it, which is the
|
||||
-- other half: the dock and the roofs smeared across the harbour.
|
||||
--
|
||||
-- So the reflection is taken with the flat view ray about the flat normal,
|
||||
-- exactly as it would be with the curve off -- and the MARCH still has to
|
||||
-- walk the world as drawn, because that is what the depth buffer holds. Both
|
||||
-- at once: the ray is straight in the flat world, and project() bends each
|
||||
-- sample on its way to the screen, which is the same displacement the vertex
|
||||
-- stage applies and therefore lands in the same place the geometry did.
|
||||
local SHADER_SRC = [[
|
||||
varying float vShade;
|
||||
varying vec3 vSun;
|
||||
@@ -435,6 +458,20 @@ uniform vec3 eye;
|
||||
uniform vec2 screen; // the canvas, in pixels
|
||||
uniform float cell; // one diorama pixel, in canvas pixels
|
||||
uniform float pxAngle; // radians of view one screen pixel subtends
|
||||
// The same bend the vertex stage applied. This stage has to undo it to get
|
||||
// back to the flat world it reflects in, and re-apply it on every marched
|
||||
// sample to get back to the screen. Declared in both stages, like `vp`, and
|
||||
// both are highp here.
|
||||
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
|
||||
|
||||
// How far the bend has pushed the world down at world XZ `q` -- the vertex
|
||||
// stage's own displacement, as a number this stage can add and subtract.
|
||||
// Zero when the curve is off, which is the shader's "skip it" everywhere.
|
||||
float bendDrop(vec2 q) {
|
||||
if (curve.z <= 0.0) return 0.0;
|
||||
vec2 d = q - curve.xy;
|
||||
return dot(d, d) * curve.z;
|
||||
}
|
||||
|
||||
// the sun's own pass, exactly as the scene shader reads it
|
||||
uniform Image sunMap;
|
||||
@@ -658,10 +695,14 @@ vec3 bodyAt(vec3 d, vec3 c, float parity) {
|
||||
// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs
|
||||
// the same way its pixel rows do, so one 0.5x+0.5 answers for both.
|
||||
//
|
||||
// This is why the march walks in the world as DRAWN rather than as authored:
|
||||
// the depth buffer holds the curved world, so a straight line in that space
|
||||
// is the ray, and a straight line in the flat one would bend through it.
|
||||
// The point arrives in the FLAT world -- the space the ray is straight in --
|
||||
// and is bent here, by the same displacement the vertex stage applied, so it
|
||||
// lands exactly where the geometry it is being compared against landed. That
|
||||
// split is the whole trick: the reflection is worked out in a world that has
|
||||
// not been tipped, and every sample of it is tipped on the way to the screen,
|
||||
// so the march reads the depth buffer it actually has.
|
||||
vec4 project(vec3 p) {
|
||||
p.y -= bendDrop(p.xz);
|
||||
vec4 c = vp * vec4(p, 1.0);
|
||||
if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0);
|
||||
return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0);
|
||||
@@ -915,8 +956,33 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
|
||||
// the bound canvas's own pixel size, the same units sc is measured in, on
|
||||
// every display. (`screen` stays in units: skyPos reads it against cell
|
||||
// and skyEdge, which are unit-measured with it.)
|
||||
//
|
||||
// The buffer now holds THIS SURFACE too (VoxelScene draws the water flat
|
||||
// before the pass that reflects it), which is what makes one lake able to
|
||||
// hide another -- and it means every fragment here is testing against its
|
||||
// own depth. That raises the bar on the fragment's own z: gl_FragCoord is
|
||||
// allowed to be MEDIUMP on GLES (and is, on Adreno), and fp16 near the far
|
||||
// end of the range steps by about half a thousandth -- which the old test
|
||||
// against the terrain far behind the surface never felt, and a comparison
|
||||
// of the surface against itself loses outright. Every fragment failed, the
|
||||
// pass discarded the whole lake, and Android showed the flat draw
|
||||
// underneath. So the depth is recomputed HERE, in highp, from the same
|
||||
// vBent and vp the vertex stage used -- full precision on every driver.
|
||||
//
|
||||
// The slack is sized to what remains after that, which is not rounding:
|
||||
// the buffer holds depth interpolated LINEARLY IN SCREEN SPACE, while the
|
||||
// recomputation projects the perspective-interpolated vBent -- the exact
|
||||
// answer. The two agree at the vertices and drift apart across a quad's
|
||||
// interior, by more the bigger the quad stands on screen; on a phone
|
||||
// (fit scale 6, water quads hundreds of pixels tall) the drift crosses
|
||||
// 1e-5 mid-quad, which discarded the middle of every tile row and looked
|
||||
// like flat water with reflective seams. Anything GENUINELY in front of a
|
||||
// water pixel is whole world units nearer -- upward of 1e-3 in depth --
|
||||
// so 2e-4 clears the drift with room while still catching every occluder.
|
||||
vec2 uv = sc / love_ScreenSize.xy;
|
||||
if (gl_FragCoord.z > Texel(depthTex, uv).r) discard;
|
||||
vec4 selfC = vp * vec4(vBent, 1.0);
|
||||
float selfZ = selfC.z / selfC.w * 0.5 + 0.5;
|
||||
if (selfZ > Texel(depthTex, uv).r + 2e-4) discard;
|
||||
|
||||
// THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of
|
||||
// its own standing a whole number of pixels tall, and the ray decides
|
||||
@@ -925,12 +991,23 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
|
||||
// drawn at, with no smooth shading anywhere across it. (The depth test
|
||||
// above is the one thing that stays per fragment: that is the hardware's
|
||||
// own question and it is asked in screen space.)
|
||||
vec3 view = normalize(vBent - eye);
|
||||
//
|
||||
// Answered on the FLAT sheet, which is where the bars are a slab of even
|
||||
// thickness over a level plane -- the one thing relief() is built on. The
|
||||
// bend translates every bar straight down by its own column's drop, so the
|
||||
// field keeps its shape and only its height moves; undo that here and the
|
||||
// walk is the walk it was written for. Try it in the world as DRAWN
|
||||
// instead and the slab is a bowl: the backward step up the ray climbs the
|
||||
// bowl's near side as fast as it climbs out of the water, the walk starts
|
||||
// inside the sheet, and it hands back a column a pixel or three off -- per
|
||||
// fragment, differently, which is a patch of noise rather than parallax.
|
||||
vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z);
|
||||
vec3 view = normalize(sheet - eye);
|
||||
vec3 hit;
|
||||
vec2 col;
|
||||
float face;
|
||||
float axis;
|
||||
relief(vBent, view, hit, col, face, axis);
|
||||
relief(sheet, view, hit, col, face, axis);
|
||||
// and the bar's centre, so a column is sampled and reflected from one
|
||||
// place rather than from wherever inside it the fragment happened to land
|
||||
vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5);
|
||||
@@ -1001,7 +1078,7 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
|
||||
vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0));
|
||||
|
||||
#ifdef VOXEL_GRID
|
||||
rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis);
|
||||
rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis);
|
||||
#endif
|
||||
return vec4(rgb, 1.0) * color;
|
||||
}
|
||||
@@ -1192,7 +1269,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)
|
||||
|
||||
@@ -23,9 +23,16 @@
|
||||
-- the engine's TILT mode -- is engine plumbing driven by the records
|
||||
-- below. This file declares; lib/ draws.
|
||||
--
|
||||
-- Nothing here reaches collision, movement, triggers or scripts. Voxel
|
||||
-- mode is purely presentational: it changes what the world LOOKS like and
|
||||
-- nothing about what it IS.
|
||||
-- Voxel mode is presentational: it changes what the world LOOKS like and
|
||||
-- nothing about what it IS. ONE rung is the deliberate exception. 1ST --
|
||||
-- the first-person camera -- replaces the grid WALK with a free,
|
||||
-- camera-relative one while it is selected (lib/FreeMove.lua), because a
|
||||
-- head you can steer with a mouse demands feet that go where it looks.
|
||||
-- Even there the game is untouched: the walk asks the engine's own
|
||||
-- collision the same questions a grid step asks, keeps the player's
|
||||
-- logical cell synced, and fires the engine's own landing pipeline per
|
||||
-- cell crossed -- warps, encounters, ledges, gates and scripts all run
|
||||
-- exactly as themselves. Step off the rung and the grid walk is back.
|
||||
|
||||
local mod = ...
|
||||
|
||||
@@ -82,6 +89,9 @@ 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")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
|
||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||
-- calls this, and it is defined further down with the settings it drives.
|
||||
@@ -161,6 +171,11 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- would fight anyone who changed one deliberately.
|
||||
applyFull(level)
|
||||
Voxel.update(dt, level)
|
||||
-- the first-person head, on the same tick: its blend in and out of the
|
||||
-- orbit, the mouse capture lifecycle, and the frame's stick-rate look.
|
||||
-- Unconditional like Voxel.update, because the blend has to keep easing
|
||||
-- OUT after the rung is left
|
||||
FirstPerson.update(dt)
|
||||
-- the day/night clock, on the same always-running tick: Pipelines.update
|
||||
-- runs whatever the level, so time passes with the mode off, through
|
||||
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
|
||||
@@ -203,20 +218,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 +390,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 = {}
|
||||
@@ -759,6 +803,32 @@ end
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- ------- the first-person rung's inputs and its walk
|
||||
--
|
||||
-- 1ST needs two things no other rung does, and each is a named seam:
|
||||
--
|
||||
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
|
||||
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
|
||||
-- on anything but the left pair), relative mouse motion (love.mousemoved --
|
||||
-- there is no Game handler to wrap; the engine's own callback only feeds
|
||||
-- the mouse-as-touch debug path, which stays untouched), the mouse buttons
|
||||
-- while the cursor is captured (A and B -- there is no cursor to click UI
|
||||
-- with), and any touch that lands off the overlay's controls (a drag on
|
||||
-- open screen is the look; the d-pad and buttons still go to
|
||||
-- TouchControls, whose own d-pad finger is also read back analog as the
|
||||
-- move vector). Every wrap forwards whatever it does not claim, and claims
|
||||
-- only while 1ST is actually driving.
|
||||
--
|
||||
-- FreeMove.install wraps OverworldState:handleInput -- the one choke point
|
||||
-- where the grid walk reads the pad, and the same seam the engine's own
|
||||
-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous
|
||||
-- and camera-relative; the player's logical cell stays synced and every
|
||||
-- per-cell consequence still runs through the engine's own machinery
|
||||
-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
|
||||
-- file argues the whole arrangement.
|
||||
FirstPerson.install()
|
||||
FreeMove.install()
|
||||
|
||||
-- The overworld's own pushBattle is the choke point for a wild encounter or
|
||||
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
|
||||
-- link battle, a script pushing a BattleState directly -- reaches this
|
||||
@@ -860,7 +930,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.4.0"
|
||||
mod.exports.version = "1.5.0"
|
||||
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
||||
-- camera without reaching into this mod's file layout
|
||||
mod.exports.lib = V
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -16,9 +16,11 @@ return {
|
||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||
"on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
|
||||
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
|
||||
},
|
||||
added = {
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
|
||||
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
|
||||
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
|
||||
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
|
||||
@@ -39,6 +41,9 @@ return {
|
||||
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
|
||||
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
|
||||
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
|
||||
"1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
|
||||
"in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
|
||||
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Scratch driver: shots of the Bike Shop showroom, for the bicycle
|
||||
-- voxelization. Two viewpoints -- the north wall (the two bikes drawn
|
||||
-- INTO the wall band) and the showroom floor (the six standing bikes).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bike] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six
|
||||
-- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5
|
||||
local SCENES = {
|
||||
{ x = 2, y = 2, face = "up", label = "wall" },
|
||||
{ x = 3, y = 3, face = "up", label = "room" },
|
||||
{ x = 2, y = 4, face = "left", label = "floor" },
|
||||
{ x = 3, y = 6, face = "up", label = "wide" },
|
||||
-- the two toolboxes, cells (6,6) and (7,7)
|
||||
{ x = 5, y = 6, face = "right", label = "tools" },
|
||||
{ x = 6, y = 4, face = "down", label = "tools2" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[bike] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[bike] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Scratch driver: shots of Bill's desk, for the `bills_desk`
|
||||
-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house
|
||||
-- with its chair in the walkable cell (1,5) below it, so these are the
|
||||
-- angles you can actually stand at: head-on from the floor two cells
|
||||
-- south, and from either flank.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bills] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 6, face = "up", label = "headon" },
|
||||
{ x = 2, y = 6, face = "up", label = "headon_e" },
|
||||
{ x = 4, y = 5, face = "left", label = "east" },
|
||||
{ x = 0, y = 5, face = "right", label = "west" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[bills] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[bills] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Scratch driver: shots of the Celadon chief's house, for the display
|
||||
-- cabinet and long table voxelizations. Three viewpoints -- the
|
||||
-- cabinet rank along the north wall, the long table in the middle of
|
||||
-- the room, and a wide shot with both in frame.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/chief")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[chief] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- the cabinets occupy cells 2..5 of rows 0-1; the long table cells
|
||||
-- 2..5 of rows 3-4; the player walks rows 2 and 5
|
||||
local SCENES = {
|
||||
{ x = 3, y = 2, face = "up", label = "cabinets" },
|
||||
{ x = 5, y = 2, face = "up", label = "bookcase" },
|
||||
{ x = 3, y = 5, face = "up", label = "table" },
|
||||
{ x = 1, y = 5, face = "right", label = "wide" },
|
||||
-- the same rank on CELADON_MANSION_1F, where it stands against the
|
||||
-- interior partition and the grids start on an ODD tile row
|
||||
{ map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up",
|
||||
label = "mansion1f" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[chief] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[chief] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
+437
-22
@@ -49,12 +49,14 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
|
||||
|
||||
-- ------- the ladders the engine drives
|
||||
|
||||
T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder")
|
||||
T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder")
|
||||
T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
|
||||
T.eq(defs.voxel.levels[2], "FULL",
|
||||
"FULL is the first rung after OFF -- the order those two get used in")
|
||||
T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera")
|
||||
T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height")
|
||||
T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera")
|
||||
T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)",
|
||||
"the top rung is the first-person camera, labelled as the experiment it is")
|
||||
T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height")
|
||||
T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
|
||||
|
||||
-- ------- gating: inert until switched on, and inert without a GPU
|
||||
@@ -145,6 +147,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 +384,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 +466,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
|
||||
@@ -1023,12 +1081,12 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
|
||||
-- with nothing on screen saying a keypress had done it.
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
local walk = {}
|
||||
for _ = 1, 6 do
|
||||
for _ = 1, 7 do
|
||||
Game.keypressed(keyGame, "3")
|
||||
walk[#walk + 1] = Pipelines.levelLabel("voxel")
|
||||
end
|
||||
T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
|
||||
"3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL")
|
||||
T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15",
|
||||
"3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL")
|
||||
|
||||
-- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to
|
||||
-- the rung that shows the same camera -- the key never appears to do nothing.
|
||||
@@ -1087,8 +1145,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
|
||||
-- TILT with or without us. Park the ladder on its top rung and turn both
|
||||
-- back on, so the single press under test is the one that wraps to OFF --
|
||||
-- where nothing else is going to clear them.
|
||||
-- 5 is the "75" rung, the last one the key walks before it wraps to OFF
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF
|
||||
Pipelines.setLevel("voxel", 6)
|
||||
Tilt.setLevel(3)
|
||||
GBCFX.setLevel(4)
|
||||
keyGame.save.options.tilt = 3
|
||||
@@ -1744,9 +1802,21 @@ T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil
|
||||
and plain:find("waveNormal(col,", 1, true) ~= nil,
|
||||
"still one answer per column, so the surface stays pixel-quantised in "
|
||||
.. "space while the value it reflects with is continuous")
|
||||
T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil,
|
||||
T.check(plain:find("relief(sheet, view, hit, col, face, axis)", 1, true) ~= nil,
|
||||
"and the visible column is found by walking the view ray through the "
|
||||
.. "slab, which is what makes a tall bar hide the short ones behind it")
|
||||
-- ...over the FLAT sheet, which is the one thing that walk is built on: an
|
||||
-- even slab over a level plane. The world curve drops each bar straight down
|
||||
-- by its own column's drop, so undoing that drop hands relief() the field it
|
||||
-- was written for. Walked in the world as DRAWN instead, the slab is a bowl:
|
||||
-- the backward step up the ray climbs the bowl's near side as fast as it
|
||||
-- climbs out of the water, the walk starts inside the sheet, and it returns a
|
||||
-- column a pixel or three off -- differently per fragment, which is a
|
||||
-- hard-edged patch of noise in the middle of a pond.
|
||||
T.check(plain:find("vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z)",
|
||||
1, true) ~= nil,
|
||||
"and it walks the sheet the mesh was AUTHORED as, the bend taken back off, "
|
||||
.. "because a slab walk over a bowl starts inside the water")
|
||||
-- the march's reach grows as one over the ray's descent, so a grazing camera
|
||||
-- asks for hundreds of world pixels of it from a fixed number of samples --
|
||||
-- which stepped over whole crests and smeared the surface into streaks
|
||||
@@ -1774,7 +1844,7 @@ T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil,
|
||||
"and the column is what is handed to it")
|
||||
|
||||
-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on
|
||||
T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil,
|
||||
T.check(gridded:find("columnSeam(hit, sheet, axis)", 1, true) ~= nil,
|
||||
"with V-GRID on, the seams outline the column the ray landed on -- every "
|
||||
.. "voxel of water its own block -- rather than ruling a grid across the "
|
||||
.. "flat quad underneath and ignoring the bars entirely")
|
||||
@@ -1783,6 +1853,25 @@ T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil,
|
||||
.. "between neighbouring fragments and its own derivative is a step")
|
||||
T.check(plain:find("march(surf, r)", 1, true) ~= nil,
|
||||
"the reflection marches from that column, not from the raw fragment")
|
||||
-- The two halves of the world curve, and they pull opposite ways. WHAT the
|
||||
-- lake reflects is worked out FLAT -- the same rule the rest of the mode
|
||||
-- keeps, that the world tips away and the things standing on it do not lean
|
||||
-- with it. Reflect off the bowl the bend has made instead and the far half
|
||||
-- of a pond is a mirror tilted twenty degrees, throwing the ray past the
|
||||
-- vertical, where the sky ramp's own measure (a screen row, through the
|
||||
-- frame's matrix) swings from one end of the ramp to the other across a
|
||||
-- single column and stamps hard-edged patches of the wrong sky into the
|
||||
-- water. But WHERE it lands has to be found in the world as DRAWN, because
|
||||
-- that is what the depth buffer holds -- so the ray stays straight in the
|
||||
-- flat world and every sample of it is bent on the way to the screen, by the
|
||||
-- vertex stage's own displacement.
|
||||
T.check(plain:find("p.y -= bendDrop(p.xz);", 1, true) ~= nil,
|
||||
"and every marched sample is bent into the world as DRAWN before it is "
|
||||
.. "projected, because that is the world the depth buffer holds")
|
||||
T.check(plain:find("vec3 r = reflect(view, n);", 1, true) ~= nil
|
||||
and plain:find("reflect(view, vec3(0.0, 1.0, 0.0))", 1, true) ~= nil,
|
||||
"while the reflection itself is taken about the FLAT normal, so a curved "
|
||||
.. "world does not tip the lake the way it does not lean the buildings")
|
||||
T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil,
|
||||
"and the dither's checkerboard is cut from the columns too, so a camera "
|
||||
.. "pan slides the world through nothing")
|
||||
@@ -1828,6 +1917,21 @@ T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil,
|
||||
.. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two "
|
||||
.. "differ by the density, which clamped the lookup and cut the water "
|
||||
.. "into blocks")
|
||||
-- ...and it tests against a buffer that now holds the water surface too,
|
||||
-- because VoxelScene lays the meshes down flat before the reflective pass
|
||||
-- runs over them. Nothing else can make one lake hide another: the pass
|
||||
-- writes no depth (the buffer is detached so it can be READ), so before this
|
||||
-- the sheets were simply painted in mesh order. Flat water never showed it --
|
||||
-- one plane, and a farther sheet always lands farther down the screen -- but
|
||||
-- the world curve drops the far side of the map into the near field of view,
|
||||
-- and a sea a hundred and fifty tiles away came out rasterised on top of the
|
||||
-- pond at the player's feet, tall grass and all.
|
||||
T.check(plain:find("vec4 selfC = vp * vec4(vBent, 1.0)", 1, true) ~= nil
|
||||
and plain:find("Texel(depthTex, uv).r + 2e-4", 1, true) ~= nil,
|
||||
"testing a HIGHP recomputed depth (gl_FragCoord.z is mediump on mobile "
|
||||
.. "GLES -- fp16 loses a self-comparison outright) with slack covering "
|
||||
.. "the buffer's screen-linear interpolation drift across big quads")
|
||||
T.check(VoxelScene.drawWater ~= nil, "and the flat draw that puts it there")
|
||||
|
||||
-- ------- the lift itself
|
||||
--
|
||||
@@ -2476,7 +2580,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 +2743,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 +2785,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 +2875,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 +2960,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
|
||||
|
||||
@@ -3102,7 +3338,10 @@ Commands.start_battle(ctx, "trainer", "RIVAL1", 1)
|
||||
T.check(viaOverworld ~= nil and pushed == nil,
|
||||
"a scripted trainer goes through pushBattle: the flash, the wipe and the "
|
||||
.. "theme, like any fight walked into")
|
||||
T.eq(viaOverworld.kind, "trainer", "with the battle it was asked to start")
|
||||
-- guarded index: when the seam above regresses, this must FAIL like any
|
||||
-- assertion rather than crash the suite half-run
|
||||
T.eq(viaOverworld and viaOverworld.kind, "trainer",
|
||||
"with the battle it was asked to start")
|
||||
ctx.overworld = nil
|
||||
Commands.start_battle(ctx, "wild", "PIDGEY", 5)
|
||||
T.check(pushed ~= nil,
|
||||
@@ -3228,6 +3467,182 @@ T.eq(g.amp, 0, "standing still fades it back out within a beat")
|
||||
T.eq(g.phase, held, "and the phase does not move while the camera does not")
|
||||
end
|
||||
|
||||
-- ------- the first-person rung
|
||||
--
|
||||
-- 1ST rides the same placed-camera seam the battle proved out, so most of
|
||||
-- what it adds is arithmetic this suite can hold still: the rig built from
|
||||
-- a pose, the compass the grid game still thinks in, the camera-relative
|
||||
-- walk vector, and the frame an NPC shows an eye that can stand anywhere.
|
||||
|
||||
do
|
||||
local FirstPerson =
|
||||
run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson")
|
||||
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
|
||||
local FreeMove = run.loader.exports.DRAMATIC_SHAPE.lib.require("FreeMove")
|
||||
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
|
||||
|
||||
T.eq(VoxelState.FP_LEVEL, 6, "1ST is the seventh rung")
|
||||
T.check(VoxelState.isFirstPerson(6), "and isFirstPerson answers for it")
|
||||
T.check(not VoxelState.isFirstPerson(5), "but not for the 75 orbit")
|
||||
T.eq(VoxelState.ANGLE_LABELS[VoxelState.FP_LEVEL + 1], "1ST (EXPERIMENTAL)",
|
||||
"the rung wears the experimental label")
|
||||
T.eq(VoxelState.ANGLES_DEG[VoxelState.FP_LEVEL + 1], 75,
|
||||
"and hands the blend the 75-degree orbit as its far end")
|
||||
|
||||
-- ------- the compass and the walk vector
|
||||
--
|
||||
-- Yaw 0 faces south (+Z), the way a resting sprite faces; the compass is
|
||||
-- the dominant axis and the walk rotates camera space into world space.
|
||||
FirstPerson.yaw, FirstPerson.pitch = 0, 0
|
||||
T.eq(FirstPerson.compassFacing(), "down", "yaw 0 looks south")
|
||||
FirstPerson.yaw = math.pi / 2
|
||||
T.eq(FirstPerson.compassFacing(), "right", "a quarter turn looks east")
|
||||
FirstPerson.yaw = math.pi
|
||||
T.eq(FirstPerson.compassFacing(), "up", "a half turn looks north")
|
||||
FirstPerson.yaw = -math.pi / 2
|
||||
T.eq(FirstPerson.compassFacing(), "left", "and three quarters looks west")
|
||||
|
||||
local function near(a, b) return math.abs(a - b) < 1e-9 end
|
||||
|
||||
FirstPerson.yaw = 0
|
||||
local wx, wz = FirstPerson.moveWorld(0, 1)
|
||||
T.check(near(wx, 0) and near(wz, 1), "facing south, forward walks south")
|
||||
wx, wz = FirstPerson.moveWorld(1, 0)
|
||||
T.check(near(wx, -1) and near(wz, 0),
|
||||
"facing south, the right hand points west")
|
||||
FirstPerson.yaw = math.pi
|
||||
wx, wz = FirstPerson.moveWorld(0, 1)
|
||||
T.check(near(wx, 0) and near(wz, -1), "facing north, forward walks north")
|
||||
wx, wz = FirstPerson.moveWorld(1, 0)
|
||||
T.check(near(wx, 1) and near(wz, 0),
|
||||
"facing north, the right hand points east")
|
||||
|
||||
-- the look clamps: pitch stops at the limits, yaw wraps
|
||||
FirstPerson.pitch = 0
|
||||
FirstPerson.lookBy(0, 100)
|
||||
T.eq(FirstPerson.pitch, FirstPerson.PITCH_DOWN, "pitch clamps looking down")
|
||||
FirstPerson.lookBy(0, -100)
|
||||
T.eq(FirstPerson.pitch, FirstPerson.PITCH_UP, "and looking up")
|
||||
FirstPerson.yaw = 0
|
||||
FirstPerson.lookBy(2 * math.pi, 0)
|
||||
T.check(math.abs(FirstPerson.yaw) < 1e-9, "a full turn of yaw wraps to zero")
|
||||
|
||||
-- ------- the rig
|
||||
--
|
||||
-- frame() is pure arithmetic over the pose and the orbit, so the suite can
|
||||
-- stand the blend anywhere and read the record it hands Voxel3D.
|
||||
FirstPerson.yaw, FirstPerson.pitch = 0, 0
|
||||
FirstPerson.blend = 1
|
||||
local me = { px = 100, py = 200, gh = 0, lift = 0 }
|
||||
local rig, sx, sy = FirstPerson.frame(me, 500, 600, 320, 288)
|
||||
T.check(rig ~= nil, "with the blend in, frame() builds a rig")
|
||||
T.eq(Voxel3D.camera, rig, "and hands it to Voxel3D")
|
||||
T.check(near(rig.eye[1], 108) and near(rig.eye[3], 208),
|
||||
"the eye stands on the player's centre")
|
||||
T.eq(rig.eye[2], FirstPerson.EYE_HEIGHT, "at head height")
|
||||
T.check(near(rig.fov, FirstPerson.FOV), "wearing the first-person lens")
|
||||
T.check(near(sx, 108) and near(sy, 208),
|
||||
"and the scene centre stands with it")
|
||||
T.check(rig.curve == 0,
|
||||
"the world curve is declined outright -- a zero, not a nil the setting "
|
||||
.. "could override")
|
||||
T.check(rig.focus[3] > rig.eye[3],
|
||||
"yaw 0 focuses south of the eye")
|
||||
|
||||
-- surf bob and ledge lift carry the eye with them
|
||||
local bobbed = FirstPerson.frame({ px = 100, py = 200, gh = 4, lift = 6 },
|
||||
500, 600, 320, 288)
|
||||
T.eq(bobbed.eye[2], 10 + FirstPerson.EYE_HEIGHT,
|
||||
"ground height and lift both raise the eye")
|
||||
|
||||
-- mid-blend, the rig is a lerp of the orbit and the head: its eye sits
|
||||
-- between the two ends, and the curve is only half declined
|
||||
VoxelState.angle = math.rad(75)
|
||||
FirstPerson.blend = 0.5
|
||||
local mid = FirstPerson.frame(me, 500, 600, 320, 288)
|
||||
T.check(mid.eye[2] > FirstPerson.EYE_HEIGHT,
|
||||
"half way out, the eye is higher than the head")
|
||||
T.check(mid.eye[2] < 288 * VoxelState.FOCAL,
|
||||
"and lower than the orbit")
|
||||
|
||||
-- ------- the cards ask the rig, and only the rig
|
||||
FirstPerson.blend = 1
|
||||
FirstPerson.frame(me, 500, 600, 320, 288)
|
||||
T.check(FirstPerson.cardBlend() == 1, "the free-roam rig turns the cards")
|
||||
T.check(FirstPerson.hidePlayer(), "and hides the player's own card")
|
||||
|
||||
-- an NPC south of the eye: the card yaws to face north, back at the eye
|
||||
local yaw = FirstPerson.cardYaw(108, 300)
|
||||
T.check(near(math.sin(yaw), 0) and near(math.cos(yaw), -1),
|
||||
"a card south of the eye turns its face north")
|
||||
|
||||
-- the frame an entity SHOWS this eye: stand north of someone facing away
|
||||
-- and you see their back; face to face you see their front; flanks show
|
||||
-- profiles, named by which flank is toward you
|
||||
T.eq(FirstPerson.apparentFacing("down", 108, 300), "up",
|
||||
"an NPC facing south, seen from the north, shows their back")
|
||||
T.eq(FirstPerson.apparentFacing("up", 108, 300), "down",
|
||||
"an NPC facing north, seen from the north, shows their face")
|
||||
T.eq(FirstPerson.apparentFacing("right", 108, 300), "left",
|
||||
"an NPC facing east, seen from the north, shows their left flank")
|
||||
T.eq(FirstPerson.apparentFacing("left", 108, 300), "right",
|
||||
"an NPC facing west, seen from the north, shows their right flank")
|
||||
|
||||
-- another camera on the same seam -- the battle's placed shot -- and the
|
||||
-- cards stand down: blend still 1, but it is not our rig drawing
|
||||
local battleCam = { eye = { 0, 40, 120 }, focus = { 0, 8, 0 },
|
||||
fov = math.rad(30) }
|
||||
Voxel3D.camera = battleCam
|
||||
T.eq(FirstPerson.cardBlend(), 0,
|
||||
"a battle camera on the seam turns no cards")
|
||||
T.check(not FirstPerson.hidePlayer(),
|
||||
"and hides nobody")
|
||||
|
||||
-- blend fully out: frame() clears only a camera that is still ours
|
||||
FirstPerson.blend = 0
|
||||
T.eq(FirstPerson.frame(me, 500, 600, 320, 288), nil,
|
||||
"with the blend out, frame() answers nil and the orbit rules")
|
||||
T.eq(Voxel3D.camera, battleCam,
|
||||
"without touching a camera some other pass placed")
|
||||
Voxel3D.camera = nil
|
||||
|
||||
-- ------- the free walk's per-cell verdict
|
||||
--
|
||||
-- The same questions Collision asks a grid step, asked per cell the body
|
||||
-- overlaps -- through a map stub shaped like the engine's own.
|
||||
local blocked = FreeMove._blockedCell
|
||||
local stubMap = {
|
||||
def = { tileset = "OVERWORLD" },
|
||||
inBounds = function(self, x, y)
|
||||
return x >= 0 and y >= 0 and x < 10 and y < 10
|
||||
end,
|
||||
isWalkableCell = function(self, x, y) return x ~= 3 end,
|
||||
isWaterCell = function(self, x, y) return x == 3 and y == 3 end,
|
||||
cellTile = function() return 0 end,
|
||||
}
|
||||
local p = { cellX = 5, cellY = 5, surfing = false }
|
||||
local state = { map = stubMap, entities = {} }
|
||||
|
||||
T.eq(blocked(state, p, 5, 5), nil, "the body's own cell never refuses it")
|
||||
T.eq(blocked(state, p, 6, 5), nil, "an open neighbour admits it")
|
||||
T.eq(blocked(state, p, 3, 5), "tile", "an unwalkable cell refuses it")
|
||||
T.eq(blocked(state, p, -1, 5), "bounds", "off the map is the edge's answer")
|
||||
T.eq(blocked(state, p, 3, 3), "tile", "water refuses a walker")
|
||||
p.surfing = true
|
||||
T.eq(blocked(state, p, 3, 3), nil, "and admits a surfer")
|
||||
p.surfing = false
|
||||
|
||||
state.entities = { { cellX = 6, cellY = 5 } }
|
||||
T.eq(blocked(state, p, 6, 5), "entity", "an occupied cell refuses the body")
|
||||
state.entities = { { cellX = 7, cellY = 5, targetX = 6, targetY = 5 } }
|
||||
T.eq(blocked(state, p, 6, 5), "entity",
|
||||
"and so does one an NPC is mid-step into")
|
||||
|
||||
-- the ladder's own state, put back the way the suite found it
|
||||
FirstPerson.blend = 0
|
||||
VoxelState.reset()
|
||||
end
|
||||
|
||||
Pipelines.reset()
|
||||
run.release()
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing
|
||||
-- in the player's head, billboards yawing to face it, the sky meeting the
|
||||
-- horizon, the shadow box following the look, water seen from eye level,
|
||||
-- and an interior with its figures.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/fpshots lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/fp")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[fp] DRAMATIC_SHAPE mod not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local DayNight = V.require("DayNight")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 0
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if FirstPerson.blend >= 1 and Voxel.ready
|
||||
and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a
|
||||
-- building footprint buries the eye in the geometry, which is what the
|
||||
-- first cut of every Pallet shot did.
|
||||
local function place(mapId, x, y)
|
||||
U.teleport(game, mapId, x, y, "down")
|
||||
local ow = game.stack:top()
|
||||
local map = ow and ow.map
|
||||
if not map or map:isWalkableCell(x, y) then return end
|
||||
for r = 1, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local cx, cy = x + dx, y + dy
|
||||
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then
|
||||
U.teleport(game, mapId, cx, cy, "down")
|
||||
print(("[fp] (%d,%d) not walkable; standing at (%d,%d)")
|
||||
:format(x, y, cx, cy))
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west
|
||||
local SCENES = {
|
||||
-- Pallet Town, mid-street: houses, the lab, NPCs -- the town seen
|
||||
-- from inside it, in each compass direction plus a diagonal
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" },
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" },
|
||||
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" },
|
||||
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4,
|
||||
label = "pallet_diag" },
|
||||
-- the shoreline: water at eye level, which is where the battle pass
|
||||
-- says a low placed camera reads the reflection wrong -- the shot
|
||||
-- decides whether 1ST keeps it
|
||||
{ map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
|
||||
-- looking up: the sky's bands and the horizon line
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = -math.rad(25), label = "pallet_skyward" },
|
||||
-- and down: the ground, the feet-level shadow
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = math.rad(45), label = "pallet_down" },
|
||||
-- Route 1: grass rows and ledges from inside them
|
||||
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
|
||||
-- an interior: the Center's counter, machines and couch figures
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
|
||||
label = "center_north" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2,
|
||||
label = "center_west" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
place(s.map, s.x, s.y)
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
FirstPerson.yaw = s.yaw
|
||||
FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
|
||||
U.wait(20)
|
||||
if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- one mid-blend shot: step the ladder onto 1ST from 75 and catch the
|
||||
-- dive halfway
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
settle()
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
for _ = 1, 300 do
|
||||
if FirstPerson.blend >= 0.5 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end
|
||||
|
||||
-- ------- the free walk, exercised
|
||||
--
|
||||
-- Hold forward with the head yawed off-grid and confirm the player
|
||||
-- GLIDES: the position moves along the look direction, lands off the
|
||||
-- 16px grid (which no grid step can do), and the logical cell follows.
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
settle()
|
||||
local ow = game.stack:top()
|
||||
local p = ow.player
|
||||
FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
local x0, y0, c0x, c0y = p.px, p.py, p.cellX, p.cellY
|
||||
U.hold(game, "up", 90)
|
||||
U.wait(5)
|
||||
local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
|
||||
print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)")
|
||||
:format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY))
|
||||
print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s")
|
||||
:format(moved,
|
||||
tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
|
||||
tostring(math.abs(p.px - x0) > 8
|
||||
and math.abs(p.py - y0) > 8)))
|
||||
if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
|
||||
|
||||
print(("[fp] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Scratch driver: shots of the Pokemon Center healing machines behind
|
||||
-- the counter, for the center_heal_machine voxelization. The pair
|
||||
-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the
|
||||
-- nurse aisle (row 2) is the row you can actually face them from, and
|
||||
-- the public floor south of the counter gives the wide view.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[heal] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 2, face = "up", label = "west_head" },
|
||||
{ x = 2, y = 2, face = "up", label = "west_keyboard" },
|
||||
{ x = 3, y = 2, face = "left", label = "west_side" },
|
||||
{ x = 6, y = 2, face = "up", label = "east_head" },
|
||||
{ x = 3, y = 4, face = "up", label = "wide" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[heal] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[heal] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Scratch driver: shots of the house dining tables and stools, for the
|
||||
-- band-table + no-desk-part voxelization. Every generic home places the
|
||||
-- table at cells (3,3):(4,4) with four stools around it (Blue's house has
|
||||
-- Daisy seated at hers); Red's and the Copycat's ground floors place the
|
||||
-- same furniture one cell lower, with the potted plant CUTOUT standing on
|
||||
-- the tabletop -- the standee the table template must support, not
|
||||
-- swallow.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[housefurn] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- head on: below Blue's table looking north over a stool at it,
|
||||
-- Daisy seated at the left one
|
||||
{ map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" },
|
||||
-- from the east, table and both east stools in profile
|
||||
{ map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" },
|
||||
-- from the north wall looking south down over the tabletop
|
||||
{ map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" },
|
||||
-- close beside a stool: seat top, legs and the gap between them
|
||||
{ map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" },
|
||||
-- Red's table head on from the south: the plant cutout standing on
|
||||
-- the modelled tabletop
|
||||
{ map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" },
|
||||
-- and from the east along the stool row, plant in profile
|
||||
{ map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" },
|
||||
-- the Fan Club's four members' chairs round the boardroom table:
|
||||
-- from the south of the west pair, both stools stacked in profile
|
||||
{ map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up",
|
||||
label = "club_west_pair" },
|
||||
-- across the table from the west, both pairs and the octagon between
|
||||
{ map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right",
|
||||
label = "club_across" },
|
||||
-- close on the east pair from the north, looking down over the seats
|
||||
{ map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down",
|
||||
label = "club_over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[housefurn] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[housefurn] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash
|
||||
-- register voxelization. The register is drawn at cell (1,5) of the 4x4
|
||||
-- shop layout every Mart shares, in the middle of the counter's east arm,
|
||||
-- so these are the three angles you can actually stand at: head-on from
|
||||
-- the aisle, side-on from the east, and over the counter's south arm.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/register")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[mart] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 7, face = "up", label = "aisle" },
|
||||
{ x = 2, y = 5, face = "left", label = "side" },
|
||||
{ x = 2, y = 6, face = "left", label = "over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[mart] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[mart] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Scratch driver: shots of the potted plants, for the plant standee
|
||||
-- voxelization. Every Center places three side-by-side pairs on its
|
||||
-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6),
|
||||
-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id,
|
||||
-- same atlas) lines four of them along its hall at cells (12,10)..(15,10).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/plants")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[plant] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- east of the left pair, looking west along the bottom row: both
|
||||
-- plants in profile, crown overhang and pot silhouette side-on
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left",
|
||||
label = "pair_side" },
|
||||
-- north of the left pair, looking south down over the crowns
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down",
|
||||
label = "pair_over" },
|
||||
-- head on: standing below the middle pair looking north at it
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up",
|
||||
label = "pair_front" },
|
||||
-- close up beside the east pair's pot
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right",
|
||||
label = "close" },
|
||||
-- the Plateau lobby's row of four (MART tileset id), along the row
|
||||
{ map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right",
|
||||
label = "lobby_row" },
|
||||
-- and head on from the hall below
|
||||
{ map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up",
|
||||
label = "lobby_front" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[plant] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[plant] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Scratch driver: the Cerulean gym and the houses beside it, shot at
|
||||
-- several camera rungs with V-CURVE walked OFF..3, to see what the world
|
||||
-- bend does to a building's roof.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[roof] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of
|
||||
-- houses along the west side give a second, smaller roof in frame
|
||||
local SCENES = {
|
||||
{ map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" },
|
||||
{ map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" },
|
||||
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
for _, curve in ipairs({ 0, 3 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
WorldCurve.setting:setIndex(curve + 1, game)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[roof] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
end
|
||||
print(("[roof] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Scratch probe: how long do a building model's merged quads get?
|
||||
--
|
||||
-- A quad's longest world-space edge is what decides how far its CHORD
|
||||
-- falls below the world curve's parabola, so this is the number that says
|
||||
-- whether the bend can crack the mesh open.
|
||||
--
|
||||
-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY"
|
||||
U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"),
|
||||
tonumber(os.getenv("BUILD_Y") or "20"), "up")
|
||||
U.wait(30)
|
||||
|
||||
local V = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
V = V and V.lib
|
||||
local Structures = V and V.require("Structures")
|
||||
local ow = game.overworld
|
||||
if not (Structures and ow and ow.map) then
|
||||
print("[span] mod or map unavailable")
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
local S = Structures.forMap(ow.map)
|
||||
local hist, worst = {}, 0
|
||||
for _, q in ipairs(S.objectQuads) do
|
||||
local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
|
||||
- math.min(q[1][1], q[2][1], q[3][1], q[4][1])
|
||||
local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
- math.min(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
- math.min(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
local span = math.max(dx, dz, dy)
|
||||
local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16"
|
||||
or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64")))
|
||||
bucket = bucket .. (q.own and " bld" or " prop")
|
||||
hist[bucket] = (hist[bucket] or 0) + 1
|
||||
if span > worst then worst = span end
|
||||
end
|
||||
print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst))
|
||||
for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do
|
||||
for _, kind in ipairs({ " bld", " prop" }) do
|
||||
print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0))
|
||||
end
|
||||
end
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Scratch driver: one shot of every OTHER user of the round-hull builder
|
||||
-- (tree canopies, boulders, hedges, stumps, the Center planter), to check
|
||||
-- that the `can` class's base cut is the identity it is supposed to be for
|
||||
-- everything that does not ask for it.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/round")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then return end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" },
|
||||
{ map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" },
|
||||
{ map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" },
|
||||
{ map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[round] capture missed: " .. path) end
|
||||
else
|
||||
print("[round] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[round] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Scratch driver: shots of the `bookcase` class across the tilesets that
|
||||
-- pin it, for the shelf-front relief. Two of them are NOT shelves --
|
||||
-- the League's gate walls and the terraces on PLATEAU -- and are here as
|
||||
-- the control: their courses run edge to edge, so nothing should sink.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[shelf] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" },
|
||||
{ map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" },
|
||||
{ map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" },
|
||||
{ map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" },
|
||||
{ map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" },
|
||||
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" },
|
||||
-- the controls, both of them tilesets that borrow the collapse for
|
||||
-- something that is NOT a shelf and say so with `bookcase_relief =
|
||||
-- false`: the League's masonry gate walls, and Bill's transporter
|
||||
-- drums. Nothing in either may move.
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" },
|
||||
{ map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" },
|
||||
-- the house shelves: pinned `desk`, NOT `bookcase`, so they go
|
||||
-- through the world mesher's box fold and this relief never reaches
|
||||
-- them. Here to show the gap.
|
||||
{ map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" },
|
||||
{ map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
-- twice: the first load of the session has no mesh to settle against
|
||||
pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[shelf] capture missed: " .. path) end
|
||||
else
|
||||
print("[shelf] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[shelf] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt.
|
||||
-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's
|
||||
-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at
|
||||
-- (4,1), and one each in the two ship-interior houses at (7,7).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[ship] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" },
|
||||
{ map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" },
|
||||
{ map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" },
|
||||
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" },
|
||||
{ map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[ship] capture missed: " .. path) end
|
||||
else
|
||||
print("[ship] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[ship] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can
|
||||
-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell
|
||||
-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's
|
||||
-- platform. Even columns are open floor, so the player can be parked
|
||||
-- between two cans and look along a row.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/cans")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[can] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- head on into the middle of the field, cans left, right and ahead
|
||||
{ x = 4, y = 12, face = "up", label = "field" },
|
||||
-- close up: standing between two cans of the bottom row
|
||||
{ x = 2, y = 11, face = "left", label = "close" },
|
||||
-- along the row, so the cans line up in depth
|
||||
{ x = 4, y = 13, face = "up", label = "row" },
|
||||
-- from the north, looking back down over all three rows
|
||||
{ x = 4, y = 6, face = "down", label = "over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[can] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[can] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -214,6 +214,172 @@ Structures.buildFigures(twice, map, 0, 3, 8, 11)
|
||||
T.eq(#twice.figures, 1,
|
||||
"the repaint replaces the pattern, so a rescan cannot match it again")
|
||||
|
||||
-- ------- a figure with a DEPTH is an object, not a card
|
||||
--
|
||||
-- The Marts' cash register: the same authored-mask escape, but a machine
|
||||
-- set down on a counter is a box seen from the front rather than a
|
||||
-- face-on icon, so it builds as a per-pixel solid. Driven over a
|
||||
-- synthetic copy of the counter's east arm, as all nine maps on the MART
|
||||
-- id draw it at cell (1,5):
|
||||
--
|
||||
-- y=9 16 41 the work surface north of it
|
||||
-- y=10 14 15 the register: keypad and receipt curl
|
||||
-- y=11 30 31
|
||||
-- y=12 16 41 the work surface it stands on
|
||||
|
||||
T.check(TileShape.figures("POKECENTER")[1].depth == nil,
|
||||
"the seated man states no depth -- he stays a flat sprite card")
|
||||
|
||||
local regs = TileShape.figures("MART")
|
||||
T.check(type(regs) == "table" and #regs == 1,
|
||||
"MART carries exactly one figure")
|
||||
local reg = regs[1]
|
||||
T.eq(reg.w, 2, "the register is two tiles across")
|
||||
T.eq(reg.h, 2, "and two tall")
|
||||
T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans")
|
||||
T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell")
|
||||
T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2,
|
||||
"the four rows above its drawn top edge are 2-voxel paper")
|
||||
T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8
|
||||
and reg.flat.r0 == 4 and reg.flat.r1 == 11,
|
||||
"and the keypad is a TOP-VIEW rect, not a face")
|
||||
|
||||
local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 },
|
||||
[11] = { 30, 31 }, [12] = { 16, 41 } }
|
||||
local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
|
||||
ground = {}, runs = {}, objectQuads = {} }
|
||||
for ty, row in pairs(MART_ROWS) do
|
||||
for i, tile in ipairs(row) do
|
||||
martS.tileAt[keyOf(1 + i, ty)] = tile
|
||||
martS.shapeAt[keyOf(1 + i, ty)] = COUNTER
|
||||
end
|
||||
end
|
||||
local martMap = {
|
||||
tileset = { id = "MART", tilesPerRow = 16,
|
||||
imageWidth = 128, imageHeight = 48 },
|
||||
isWalkableCell = function() return false end,
|
||||
}
|
||||
Structures.buildFigures(martS, martMap, 2, 3, 9, 12)
|
||||
|
||||
T.eq(#martS.figures, 0, "no card was built -- it is a solid")
|
||||
T.eq(#martS.objectQuads, 351,
|
||||
"and it landed in the standee channel as 351 quads")
|
||||
T.eq(martS.tileAt[keyOf(2, 10)], 16,
|
||||
"its tiles wear the plain work surface now")
|
||||
T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them")
|
||||
T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter",
|
||||
"and keep the counter box the machine stands on")
|
||||
|
||||
local rx0, rx1, ry0, ry1, rz0, rz1
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do
|
||||
local p = q[c]
|
||||
rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1])
|
||||
ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2])
|
||||
rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3])
|
||||
end
|
||||
end
|
||||
T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor")
|
||||
T.eq(ry1, 24, "and is its drawn 16px tall")
|
||||
T.eq(rx0, 18, "west edge at the mask's column 2")
|
||||
T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)")
|
||||
T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn")
|
||||
T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back")
|
||||
|
||||
-- the two thicknesses: the body at 8, the receipt curl at 2, the curl
|
||||
-- centred in the body's own band rather than flush with its front
|
||||
local bands = {}
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do bands[q[c][3]] = true end
|
||||
end
|
||||
for _, z in ipairs({ 84, 89, 91, 96 }) do
|
||||
T.check(bands[z], "the model has a face at z = " .. z)
|
||||
end
|
||||
local curl = {}
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end
|
||||
end
|
||||
T.check(curl[89] and curl[91] and not curl[84] and not curl[96],
|
||||
"clear of the arm's top only the 2-voxel paper band exists")
|
||||
|
||||
-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and
|
||||
-- the keypad lies on it as a horizontal plate, so the whole machine is
|
||||
-- exactly three surfaces: a foot, an arm, and a deck in the notch.
|
||||
local plate, deckTop = {}, 0
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2]
|
||||
and q[3][2] == q[4][2]
|
||||
if flatQuad and q[1][2] == 13 then
|
||||
plate[#plate + 1] = q
|
||||
elseif flatQuad and q[1][2] == 12 then
|
||||
deckTop = deckTop + 1
|
||||
end
|
||||
end
|
||||
T.eq(#plate, 83,
|
||||
"the keypad lies FLAT: one top quad per masked voxel of the deck")
|
||||
T.eq(deckTop, 7,
|
||||
"on the base band's own top, which is 4 voxels up (drawn rows 12-15)")
|
||||
local dz0, dz1
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
if q[1][2] == 12 and q[3][2] == 12 then
|
||||
for c = 1, 4 do
|
||||
dz0 = math.min(dz0 or q[c][3], q[c][3])
|
||||
dz1 = math.max(dz1 or q[c][3], q[c][3])
|
||||
end
|
||||
end
|
||||
end
|
||||
T.eq(dz0, 84, "and that deck runs the body's whole depth")
|
||||
T.eq(dz1, 96, "-- plain behind the panel, covered by it in front")
|
||||
|
||||
local px0, px1, pz0, pz1
|
||||
for _, q in ipairs(plate) do
|
||||
for c = 1, 4 do
|
||||
px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1])
|
||||
pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3])
|
||||
end
|
||||
end
|
||||
T.eq(px0, 18, "the deck spans the mask's columns 2..8")
|
||||
T.eq(px1, 25, "-- the keypad panel and its own black rim")
|
||||
T.eq(pz1, 96, "the deck reaches the body's front edge")
|
||||
T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels")
|
||||
|
||||
-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12
|
||||
-- voxels of deck doubles every second one and blurs nothing
|
||||
local perRow16, atlasH16 = 16, 48
|
||||
local depthRow = {}
|
||||
for _, q in ipairs(plate) do
|
||||
local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
depthRow[z] = math.floor(q.v * atlasH16)
|
||||
end
|
||||
local seen = {}
|
||||
for z = 84, 95 do
|
||||
T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel")
|
||||
seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1
|
||||
end
|
||||
T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim")
|
||||
T.eq(depthRow[84], 4, "the back one wears its top rim")
|
||||
local doubled = 0
|
||||
for _, n in pairs(seen) do
|
||||
T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels")
|
||||
if n == 2 then doubled = doubled + 1 end
|
||||
end
|
||||
T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12")
|
||||
|
||||
|
||||
-- and the arm still stands its drawn 8 rows above that deck, carrying
|
||||
-- the paper: nothing in the notch reaches higher than the plate
|
||||
local armTop, notchTop = 0, 0
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do
|
||||
if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2])
|
||||
elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end
|
||||
end
|
||||
end
|
||||
T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px")
|
||||
T.eq(notchTop, 23,
|
||||
"and west of it only the keys (13) and the paper overhanging them")
|
||||
|
||||
-- ------- prop_bg: the shades a pinned prop treats as background
|
||||
--
|
||||
-- The potted plants needed this: their pot's olive base is drawn flush on
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the
|
||||
-- world bend does to the reflective pass.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[water] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local Water = V.require("Water")
|
||||
-- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to
|
||||
-- tell an artefact of the one from an artefact of the other
|
||||
if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync(os.getenv("WATER_TIME") or "night")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- Stand on the walkable cell just north of the widest run of water on the
|
||||
-- map, so a scene is picked by where the water actually is rather than by
|
||||
-- a coordinate guessed off the block list.
|
||||
local TileShape = V.require("TileShape")
|
||||
local function shore(map)
|
||||
local def = map.def
|
||||
local shapes = TileShape.forMap(map)
|
||||
local function classAt(cx, cy)
|
||||
local tx, ty = cx * 2, cy * 2
|
||||
local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty)
|
||||
return s and s.class
|
||||
end
|
||||
local best, bestRun = nil, 0
|
||||
for cy = 1, def.height * 2 - 1 do
|
||||
local run, start = 0, nil
|
||||
for cx = 0, def.width * 2 - 1 do
|
||||
if classAt(cx, cy) == "water" then
|
||||
start = start or cx
|
||||
run = run + 1
|
||||
if run > bestRun and classAt(cx, cy - 1) ~= "water" then
|
||||
bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 }
|
||||
end
|
||||
else
|
||||
run, start = 0, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "PALLET_TOWN", label = "pallet" },
|
||||
{ map = "CERULEAN_CITY", label = "cerulean" },
|
||||
{ map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" },
|
||||
{ map = "ROUTE_24", label = "route24" },
|
||||
{ map = "VIRIDIAN_CITY", label = "viridian" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
if not s.x then
|
||||
-- teleport once so the map is loaded, then let the scan place us
|
||||
U.teleport(game, s.map, 1, 1, "down")
|
||||
U.wait(4)
|
||||
local spot = game.overworld and game.overworld.map and shore(game.overworld.map)
|
||||
if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end
|
||||
print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y))
|
||||
end
|
||||
for _, rung in ipairs({ 3, 4 }) do
|
||||
for _, curve in ipairs({ 0, 3 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face or "down")
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
WorldCurve.setting:setIndex(curve + 1, game)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[water] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
end
|
||||
print(("[water] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
+1217
-13
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user