Compare commits

..

1 Commits

Author SHA1 Message Date
DramaticShape c82598b24c Merge pull request #51 from DramaticShape/back-sprite-transparency-fix
Back sprite transparency fix
2026-08-01 16:44:57 -04:00
34 changed files with 510 additions and 7328 deletions
-254
View File
@@ -1,259 +1,5 @@
# 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
+5 -20
View File
@@ -32,26 +32,11 @@ 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.
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.
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.
## Controls
+81 -1145
View File
File diff suppressed because it is too large Load Diff
+85 -527
View File
@@ -68,47 +68,6 @@ 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
@@ -211,39 +170,6 @@ 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
@@ -350,14 +276,6 @@ 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
@@ -382,12 +300,7 @@ local function measure(sp, t)
-- 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),
return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8,
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
end
@@ -430,367 +343,19 @@ local function deskSetModel(sp, pr, t)
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
local y = ground - 1 - sy
for sx = 0, W - 1 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
local ix = interiorAt(sx, sy, 0, W - 1)
for z = 0, D - 1 do
local px = (z == 0 or z == D - 1) and sx or ix
put(sx, y, z, sy * W + px)
end
end
@@ -798,74 +363,92 @@ local function deskSetModel(sp, pr, t)
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
if sy >= b0 and sy <= b1 then
vox[key(i % W, ground - 1 - sy, D - 1)] = nil
end
end
-- the slab: fascia rows wrap every side
-- the slab: fascia rows wrap every side; the lid continues the
-- sibling tables' top -- black rim, white highlight courses along
-- the north and west, grey field
for sy = f0, f1 do
Budget.tick()
local y = plane - 1 - (sy - f0)
for sx = dx0, dx1 do
for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
for sx = 0, W - 1 do
for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
end
end
local field = t.desk.lid == "white" and WHITE or GREY
for sx = 0, W - 1 do
for z = 0, D - 1 do
local shade = field
if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
shade = BLACK
elseif sx == 1 or z == 1 then
shade = WHITE
end
put(sx, plane - 1, z, pr.shadeTexel[shade])
end
end
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
local ytop = plane
for _, p in ipairs(t.parts) do
Budget.tick()
local x0, x1 = p.x[1], p.x[2]
if p.kind == "flat" then
-- drawn row = depth row by default; `z` renames the origin when
-- the flat sits below the desk's own drawn top span (the Center
-- PC's keyboard)
local r0 = p.rows[1]
local z0 = p.z or r0
for sy = r0, p.rows[2] do
local z = z0 + (sy - r0)
if z >= 0 and z < D then
for sx = x0, x1 do
if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
end
end
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
else
local tr0, tr1 = p.top[1], p.top[2]
local fr0, fr1 = p.facade[1], p.facade[2]
local pd = p.depth
local ytp = plane + (fr1 - fr0)
if ytp > ytop then ytop = ytp end
for sx = x0, x1 do
-- the lid: the part's drawn top laid across its depth from the
-- back, last row continuing forward; the front lid row is the
-- facade's own top row -- the drawn front-top edge
for z = 0, pd - 1 do
local front = z == pd - 1
local sy = front and fr0 or math.min(tr0 + z, tr1)
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
if ok then
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
end
end
-- the body: facade rows anchored to the desk's top plane
for sy = fr0 + 1, fr1 do
local y = plane + (fr1 - sy)
local i = sy * W + sx
if inside[i] then
local ix = interiorAt(sx, sy, x0, x1)
for z = 0, pd - 1 do
if z == pd - 1 then
if not pr.recess[i] then put(sx, y, z, i) end
elseif z == 0 then
put(sx, y, z, i)
else
put(sx, y, z, sy * W + ix)
end
end
end
end
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)]
@@ -1055,8 +638,7 @@ 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
local cap = runCap(x)
while n < cap do
while true 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
@@ -1148,8 +730,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, cap = 1, runCap(z)
while n < cap and z + n <= zmax do
local n = 1
while 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
@@ -1258,7 +840,7 @@ function Buildings.build(S, map, data, perRow)
end
built = models[key]
end
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
Buildings.stamp(S, map, built, tx, ty, bw, bh)
end
end
end
@@ -1268,24 +850,9 @@ 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.
--
-- 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
function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
local shape = { class = "building", h = 0, art = "building",
flat = false, authored = true }
-- the ground the building stands on: the commonest flat tile around its
-- feet, so a house on a path keeps its path
@@ -1311,18 +878,9 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
for r = 0, bh - 1 do
for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r)
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
S.shapeAt[k] = shape
S.skip[k] = true
S.ground[k] = best or false
end
end
+5 -15
View File
@@ -853,24 +853,14 @@ 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, 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.
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
-- none, so the loop that draws them is shorter than the terrain's.
local function buildFigureMeshes(map)
local out = {}
for _, f in ipairs(Structures.forMap(map).figures or {}) do
local mesh = quadsMesh(f.quads)
if mesh then
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 }
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
end
end
return out
@@ -1142,8 +1132,8 @@ function ChunkMesher.flowers(map)
return c and c.flowers or nil
end
-- 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.
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
-- own leaning matrix at draw time, so they cannot share one mesh.
function ChunkMesher.figures(map)
local c = cache[map.id]
local list = c and c.figures
-713
View File
@@ -1,713 +0,0 @@
-- 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
-345
View File
@@ -1,345 +0,0 @@
-- 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
+154 -1099
View File
File diff suppressed because it is too large Load Diff
+40 -177
View File
@@ -71,21 +71,6 @@ 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,
@@ -106,10 +91,6 @@ local FALLBACK_HEIGHTS = {
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,
@@ -146,8 +127,6 @@ 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
@@ -186,13 +165,6 @@ 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
@@ -211,7 +183,6 @@ 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
@@ -459,158 +430,68 @@ 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 -- 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.
-- No class: a figure is always a flat sprite card, drawn the way
-- SpriteBillboards draws a character (see Structures.buildFigures).
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
--
-- `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 out = authoredMasks(entry and entry.figures)
local list = entry and entry.figures
local out = {}
if type(list) == "table" then
for _, f in ipairs(list) do
local ok = type(f) == "table" and type(f.w) == "number"
and type(f.tiles) == "table" and type(f.under) == "table"
and type(f.pixels) == "table"
local w = ok and math.floor(f.w) or 0
local h = (w >= 1) and (#f.tiles / w) or 0
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
and #f.under == #f.tiles and #f.pixels == h * 8
if ok then
for i = 1, h * 8 do
local row = f.pixels[i]
if type(row) ~= "string" or #row ~= w * 8 then
ok = false
break
end
end
end
if ok then
local mask, n = {}, 0
for ly = 0, h * 8 - 1 do
local row = f.pixels[ly + 1]
for lx = 0, w * 8 - 1 do
if row:sub(lx + 1, lx + 1) ~= "." then
mask[ly * (w * 8) + lx] = true
n = n + 1
end
end
end
if n > 0 then
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
end
end
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
-- 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.
--
@@ -683,30 +564,12 @@ 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
+4 -8
View File
@@ -421,8 +421,7 @@ 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,
-- up = {x,y,z} or nil }.
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
--
-- The orbit is the free-roam camera and it is described entirely by ONE
-- number, the pitch, because that is all a camera following the player over
@@ -497,12 +496,9 @@ 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 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 }))
-- world up, so the horizon stays level -- a placed camera that rolled
-- with its own pitch would tip the whole arena
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
end
local a = Voxel.angle
+15 -144
View File
@@ -24,7 +24,6 @@ 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")
@@ -215,22 +214,6 @@ 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
@@ -253,22 +236,10 @@ 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 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)))
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
Mat4.rotateX(Voxel.angle - math.pi / 2))
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
end
@@ -287,24 +258,10 @@ 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")
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)))
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.rotateX(Voxel.angle - math.pi / 2))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
@@ -376,7 +333,7 @@ VoxelScene.drawEntity = drawEntity
-- mesh for it.
local function drawGhost(p)
local def = p.sprite.def
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
local tex = p.sprite:resolveImage()
@@ -505,13 +462,7 @@ 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]
-- 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
if e == state.player then me = posed[#posed] end
end
end
return posed, me
@@ -578,18 +529,9 @@ 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
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
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
-- back on for everything textured from the atlas again -- figures, grass
-- and flowers all sample it, where the mask's coordinates are honest
@@ -641,48 +583,8 @@ 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)
-- 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
local plain = true
if Water.enabled() and Voxel3D.depthReadable() then
local mirror, depth = Voxel3D.beginWater(cast)
local w, h = Voxel3D.size()
@@ -705,12 +607,10 @@ 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 every pass after it runs
-- with no shader and no depth test.
-- exactly like one that succeeded -- otherwise the plain draw below (and
-- 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])
@@ -745,10 +645,6 @@ 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
@@ -821,12 +717,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
end
for _, p in ipairs(posed) do
local def = p.sprite.def
-- 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 frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if mesh then
ShadowMap.draw(mesh, p.sprite:resolveImage(),
@@ -882,23 +773,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
end
local posed, me = posesOf(state, spriteColors)
-- 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,
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
water, nbWater)
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
@@ -921,7 +796,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, viewFacing(p), p.phase, p.flip, p.gh,
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.lift)
end
Voxel3D.endShadows()
@@ -967,11 +842,7 @@ 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.
--
-- 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
if me then
Voxel3D.beginGhost()
drawGhost(me)
Voxel3D.endGhost()
+3 -25
View File
@@ -32,18 +32,8 @@ 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.
--
-- 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.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
-- the rung FULL sits on, so nothing has to hunt for it by label
@@ -53,13 +43,6 @@ 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
@@ -68,12 +51,7 @@ 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.
--
-- 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
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
-- The rung a press moves to from `level`.
--
+10 -87
View File
@@ -381,32 +381,9 @@ 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 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.
-- 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.)
local SHADER_SRC = [[
varying float vShade;
varying vec3 vSun;
@@ -458,20 +435,6 @@ 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;
@@ -695,14 +658,10 @@ 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.
//
// 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.
// 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.
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);
@@ -956,33 +915,8 @@ 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;
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;
if (gl_FragCoord.z > Texel(depthTex, uv).r) 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
@@ -991,23 +925,12 @@ 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.)
//
// 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 view = normalize(vBent - eye);
vec3 hit;
vec2 col;
float face;
float axis;
relief(sheet, view, hit, col, face, axis);
relief(vBent, 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);
@@ -1078,7 +1001,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, sheet, axis);
rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis);
#endif
return vec4(rgb, 1.0) * color;
}
+4 -44
View File
@@ -23,16 +23,9 @@
-- the engine's TILT mode -- is engine plumbing driven by the records
-- below. This file declares; lib/ draws.
--
-- 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.
-- 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.
local mod = ...
@@ -90,8 +83,6 @@ 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.
@@ -171,11 +162,6 @@ 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
@@ -803,32 +789,6 @@ 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
@@ -930,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.5.0"
mod.exports.version = "1.4.1"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.5.0",
"version": "1.4.1",
"api": 2,
"entry": "main.lua",
"profile": "content",
+1 -6
View File
@@ -16,11 +16,9 @@ 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 / 1ST, a first-person camera with free look and free movement)",
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"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",
@@ -41,9 +39,6 @@ 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 = {
-79
View File
@@ -1,79 +0,0 @@
-- 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
-77
View File
@@ -1,77 +0,0 @@
-- 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
-81
View File
@@ -1,81 +0,0 @@
-- 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
+11 -238
View File
@@ -49,14 +49,12 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
-- ------- the ladders the engine drives
T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder")
T.eq(#defs.voxel.levels, 6, "voxel exposes a six-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", "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(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(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
-- ------- gating: inert until switched on, and inert without a GPU
@@ -1081,12 +1079,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, 7 do
for _ = 1, 6 do
Game.keypressed(keyGame, "3")
walk[#walk + 1] = Pipelines.levelLabel("voxel")
end
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")
T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
"3 walks OFF -> 15 -> 35 -> 50 -> 75 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.
@@ -1145,8 +1143,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.
-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF
Pipelines.setLevel("voxel", 6)
-- 5 is the "75" rung, the last one the key walks before it wraps to OFF
Pipelines.setLevel("voxel", 5)
Tilt.setLevel(3)
GBCFX.setLevel(4)
keyGame.save.options.tilt = 3
@@ -1802,21 +1800,9 @@ 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(sheet, view, hit, col, face, axis)", 1, true) ~= nil,
T.check(plain:find("relief(vBent, 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
@@ -1844,7 +1830,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, sheet, axis)", 1, true) ~= nil,
T.check(gridded:find("columnSeam(hit, vBent, 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")
@@ -1853,25 +1839,6 @@ 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")
@@ -1917,21 +1884,6 @@ 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
--
@@ -3338,10 +3290,7 @@ 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")
-- 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")
T.eq(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,
@@ -3467,182 +3416,6 @@ 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()
-159
View File
@@ -1,159 +0,0 @@
-- 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
-78
View File
@@ -1,78 +0,0 @@
-- 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
-98
View File
@@ -1,98 +0,0 @@
-- 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
-76
View File
@@ -1,76 +0,0 @@
-- 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
-91
View File
@@ -1,91 +0,0 @@
-- 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
-80
View File
@@ -1,80 +0,0 @@
-- 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
-47
View File
@@ -1,47 +0,0 @@
-- 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
-75
View File
@@ -1,75 +0,0 @@
-- 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
-92
View File
@@ -1,92 +0,0 @@
-- 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
-78
View File
@@ -1,78 +0,0 @@
-- 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
-80
View File
@@ -1,80 +0,0 @@
-- 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
-166
View File
@@ -214,172 +214,6 @@ 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
-121
View File
@@ -1,121 +0,0 @@
-- 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
+91 -1002
View File
File diff suppressed because it is too large Load Diff