Compare commits

...

2 Commits

Author SHA1 Message Date
DramaticShape 1e08f09c27 a few fixes 2026-08-01 14:51:55 -04:00
DramaticShape c9d7e26858 first ledge commit 2026-08-01 12:04:51 -04:00
10 changed files with 289 additions and 74 deletions
+59 -1
View File
@@ -1,6 +1,64 @@
# Changelog # Changelog
## 1.4.0 ## Unreleased
### Changed
- **Ledges are cliffs now.** A ledge used to be taken at face value: a 6px
speed bump extruded out of a flat world. But the drawing is the game
telling you about terrain -- the side you hop FROM is higher ground --
so the world now has real elevation: everything above a hop-down edge
stands one ledge-height (6px) up, the lip sits flush with the plateau it
rims, and its south face is the cliff drop wearing the same cropped lip
art it always wore. Stack ledges and the tiers stack; Pallet Town is the
sea-level datum at 0, and the terrain tops out 22px up.
The subtlety is that ledges do not enclose anything -- every one can be
walked around through a gap, so a plateau flood fill would leak through
it, meet itself across its own ledge line and conclude an area sits a
tier above itself. So lib/Elevation.lua closes the lines before it
fills anything. Ledge cells group into runs, and each run is extended
along its own axis from either end -- across the gap the player detours
through -- until it reaches something that closes it: unwalkable
ground, another ledge, or the edge of the world. A run that finds
nothing within reach is not a cliff anybody walks around and stays
open. The cells that extension crosses become STEPS, and they are the
one piece of sloped ground in the world: their tiles grade into 4px and
2px treads, so walking a sealed gap climbs three crisp 2px risers where
the cliff line crosses it.
With every line closed, an ordinary flood over the walkable ground cuts
the world into areas -- and now they really are the areas enclosed by
ledges. Those areas are then levelled against each other in whole
tiers, by least squares over the area graph rather than by propagation:
each lip votes "the area behind me is one tier over the area in front",
areas meeting across ordinary trees or water cast a small
same-ground vote, and a stray lip is outvoted by the run it disagrees
with instead of tipping half a route. Because each area is a single
variable the result is exactly FLAT -- these are plateaus, not a
smoothed field. The solve is global and anchored on Pallet's own
ground, so two connected maps never disagree about a seam; it runs once
inside the build budget, in well under a second for all 43k cells of
Kanto, and cuts them into 199 areas rising six tiers from Pallet at 0 to
the Route 22/23 highlands at 42px.
Roughly three quarters of the ledges with standable ground on both
sides come out as an exact one-tier drop. The rest -- and the ones
whose high side is impassable mountain rock, where there is no plateau
to stand on at all -- take the level of the ground around them, which
reads as terrain rather than as a mistake, but they are the cases still
worth an eye in-game.
Everything that stands on the ground rides it: characters and NPCs
(including ghosts on neighbour maps), grass tufts, flowers, props,
buildings (each on one flattened pad -- no terraced floorboards), tree
hulls, battle arenas and their camera rig, cast shadows (the sun
frustum grows by the tallest base), and the free-roam camera's focus,
which eases after the player's ground height instead of pinning to the
old flat plane. Cliff skirts fall out of the mesher's own
neighbour-difference rule, banded in cell-local height so every crop
the flat world drew is byte-identical there. Interiors and any map not
connected to Pallet keep the classic flat reading, ledge bumps and all.
### Added ### Added
+4
View File
@@ -98,6 +98,10 @@ return {
ground = 0, ground = 0,
water = -2, water = -2,
void = 0, void = 0,
-- doubles as the terrain TIER: on the connected overworld the ground
-- above a hop-down edge stands this many pixels up (lib/Elevation.lua)
-- and the lip's box sinks by the same amount to sit flush as its rim,
-- so retuning it retunes the cliffs with the faces that clothe them
ledge = 6, ledge = 6,
fence = 10, fence = 10,
sign = 12, sign = 12,
+8 -2
View File
@@ -242,13 +242,19 @@ end
-- Whether both mons would be in plain view from the battle camera. -- Whether both mons would be in plain view from the battle camera.
function BattleArena.clearance(map, arena) function BattleArena.clearance(map, arena)
local BattleCam = V.require("BattleCam") local BattleCam = V.require("BattleCam")
local ok, rig = pcall(BattleCam.rig, arena, 0) -- rig and rays at the arena's OWN floor height: on solved terrain a
-- fight on a plateau stands (and is judged) that many pixels up, or
-- the raised ground itself would read as an obstacle over every mon
local gy = heightAt(map, arena.player[1], arena.player[2])
local ok, rig = pcall(BattleCam.rig, arena, gy)
if not (ok and rig and rig.eye) then return true end if not (ok and rig and rig.eye) then return true end
local eye = rig.eye local eye = rig.eye
local H = BattleArena.MON_H local H = BattleArena.MON_H
for _, mark in ipairs({ arena.player, arena.enemy }) do for _, mark in ipairs({ arena.player, arena.enemy }) do
for _, hy in ipairs({ 1, H * 0.5, H }) do for _, hy in ipairs({ 1, H * 0.5, H }) do
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end if not lineClear(map, eye, mark[1], gy + hy, mark[2]) then
return false
end
end end
end end
return true return true
+11 -4
View File
@@ -701,12 +701,19 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
vote(tx + bw, ty + r) vote(tx + bw, ty + r)
end end
-- One building, ONE base: the model is rigid, so it stands at the
-- elevation under its door row and the pad beneath is flattened to
-- match -- a house near a ramp must not have terraced floorboards.
local my = S.base
and S.base[keyOf(tx + math.floor(bw / 2), ty + bh - 1)] or 0
for r = 0, bh - 1 do for r = 0, bh - 1 do
for c = 0, bw - 1 do for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r) local k = keyOf(tx + c, ty + r)
S.shapeAt[k] = shape S.shapeAt[k] = shape
S.skip[k] = true S.skip[k] = true
S.ground[k] = best or false S.ground[k] = best or false
if S.base then S.base[k] = my ~= 0 and my or nil end
end end
end end
@@ -714,10 +721,10 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
local out = S.objectQuads local out = S.objectQuads
for _, q in ipairs(quads) do for _, q in ipairs(quads) do
out[#out + 1] = { out[#out + 1] = {
{ q[1][1] + mx, q[1][2], q[1][3] + mz }, { q[1][1] + mx, q[1][2] + my, q[1][3] + mz },
{ q[2][1] + mx, q[2][2], q[2][3] + mz }, { q[2][1] + mx, q[2][2] + my, q[2][3] + mz },
{ q[3][1] + mx, q[3][2], q[3][3] + mz }, { q[3][1] + mx, q[3][2] + my, q[3][3] + mz },
{ q[4][1] + mx, q[4][2], q[4][3] + mz }, { q[4][1] + mx, q[4][2] + my, q[4][3] + mz },
uv = q.uv, shade = q.shade, uv = q.uv, shade = q.shade,
-- placements only ever scan the BODY, so a building is always this -- placements only ever scan the BODY, so a building is always this
-- map's own structure: the mesher's edge keep-rules must not eat -- map's own structure: the mesher's edge keep-rules must not eat
+69 -33
View File
@@ -239,13 +239,22 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
local atlasW = tileset.imageWidth or (perRow * 8) local atlasW = tileset.imageWidth or (perRow * 8)
local atlasH = tileset.imageHeight or 48 local atlasH = tileset.imageHeight or 48
-- The terrain's base elevation under a tile (0 on a map without a
-- solved field -- every interior), and the ABSOLUTE height of what
-- stands there: base + the shape's own extrusion. Side faces are
-- derived from neighbour height differences, so once every height is
-- measured from the same datum the cliff skirt under a raised cell
-- falls out of the same band loop that has always clothed walls.
local baseAt = S.base and function(k) return S.base[k] or 0 end
or function() return 0 end
local function heightAt(tx, ty) local function heightAt(tx, ty)
local k = keyOf(tx, ty) local k = keyOf(tx, ty)
if S.skip[k] then return 0 end if S.skip[k] then return baseAt(k) end
local run = S.runs[k] local run = S.runs[k]
if run then return run.h end if run then return baseAt(k) + run.h end
local s = S.shapeAt[k] local s = S.shapeAt[k]
return s and s.h or 0 return baseAt(k) + (s and s.h or 0)
end end
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8 -- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
@@ -344,13 +353,17 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- blocks half the sky, so the closer a voxel sits to it the less ambient -- blocks half the sky, so the closer a voxel sits to it the less ambient
-- light reaches it -- which is what plants a prop on the floor instead -- light reaches it -- which is what plants a prop on the floor instead
-- of leaving it looking pasted over the top. -- of leaving it looking pasted over the top.
-- `floor` is the terrain base the prop stands on: contact darkening
-- measures height above the prop's OWN ground, not above the world
-- datum, or every plant on a plateau would lose its feet.
local aoProp = { 0, 0, 0, 0 } local aoProp = { 0, 0, 0, 0 }
local function groundShades(c, shade) local function groundShades(c, shade, floor)
if type(shade) == "table" then return shade end if type(shade) == "table" then return shade end
floor = floor or 0
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2] local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
if math.min(y1, y2, y3, y4) >= AO_RISE then return shade end if math.min(y1, y2, y3, y4) - floor >= AO_RISE then return shade end
for i = 1, 4 do for i = 1, 4 do
local t = c[i][2] / AO_RISE local t = (c[i][2] - floor) / AO_RISE
aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t))) aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t)))
end end
return aoProp return aoProp
@@ -456,28 +469,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- prebuilt prism quads (appended below) carry the art -- prebuilt prism quads (appended below) carry the art
local g = S.ground[k] local g = S.ground[k]
if g then if g then
topQuad(tx * 8, ty * 8, 0, g, 1) local b = baseAt(k)
-- the claimed tile is still ground at height 0, and water next topQuad(tx * 8, ty * 8, b, g, 1)
-- the claimed tile is still ground at its base, and water next
-- door still recesses below it: without the same below-ground -- door still recesses below it: without the same below-ground
-- side bands ordinary ground emits, the two-pixel shoreline -- side bands ordinary ground emits, the two-pixel shoreline
-- face is a slit into the sky behind the mesh -- which is -- face is a slit into the sky behind the mesh -- which is
-- exactly what a building plot or a sign standing at the -- exactly what a building plot or a sign standing at the
-- waterline showed. Same bands, cut from the synthesized -- waterline showed. Same bands, cut from the synthesized
-- ground's own art -- ground's own art. Bands run in CELL-LOCAL height (world
-- minus base), so the crop is the one the flat world always
-- drew, translated up with the terrain.
for _, side in ipairs(SIDES) do for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2]) local nh = heightAt(tx + side[1], ty + side[2])
if nh < 0 then if nh < b then
local d = side[3] local d = side[3]
local lat = LATERAL[d] local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0 local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0 local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), -1 do local nl = nh - b
local y0 = math.max(nh, band * 8) for band = math.floor(nl / 8), -1 do
local y1 = math.min(0, band * 8 + 8) local ly0 = math.max(nl, band * 8)
if y1 > y0 then local ly1 = math.min(0, band * 8 + 8)
sideQuad(d, tx * 8, ty * 8, y0, y1, g, if ly1 > ly0 then
(band * 8 + 8) - y1, (band * 8 + 8) - y0, sideQuad(d, tx * 8, ty * 8, b + ly0, b + ly1, g,
sideShades(hl, hr, y0, y1, y0 <= nh, (band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
sideShades(hl, hr, b + ly0, b + ly1, ly0 <= nl,
Voxel3D.FACE_SHADE[d])) Voxel3D.FACE_SHADE[d]))
end end
end end
@@ -486,7 +503,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
end end
elseif s then elseif s then
local run = S.runs[k] local run = S.runs[k]
local h = run and run.h or s.h local b = baseAt(k)
-- h is ABSOLUTE (base + extrusion), matching heightAt; hLocal is
-- the extrusion alone, which is the space the art bands live in
local hLocal = run and run.h or s.h
local h = b + hLocal
local x0, z0 = tx * 8, ty * 8 local x0, z0 = tx * 8, ty * 8
-- top face. A roofed volume gets a GABLE segment: the roof rises -- top face. A roofed volume gets a GABLE segment: the roof rises
@@ -502,9 +523,10 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- everything else its own art. -- everything else its own art.
if run and run.rise > 0 then if run and run.rise > 0 then
local mid = run.extent / 2 local mid = run.extent / 2
local runTop = b + run.h -- the facade top, absolute
local function gableH(d) -- d = rows north of the south eave local function gableH(d) -- d = rows north of the south eave
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid) local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
return run.h + run.rise * math.max(0, math.min(1, t)) return runTop + run.rise * math.max(0, math.min(1, t))
end end
local d0 = run.front - ty -- rows from the south edge local d0 = run.front - ty -- rows from the south edge
local hS = gableH(d0) local hS = gableH(d0)
@@ -515,13 +537,13 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
math.floor((1 - rel) * run.roofRows)) math.floor((1 - rel) * run.roofRows))
local roofTile = map:tileAt(tx, run.north + idx) local roofTile = map:tileAt(tx, run.north + idx)
local swY, seY, neY, nwY = hS, hS, hN, hN local swY, seY, neY, nwY = hS, hS, hN, hN
if heightAt(tx - 1, ty) < run.h then -- west flank: hip if heightAt(tx - 1, ty) < runTop then -- west flank: hip
swY = math.max(run.h, hS - 8) swY = math.max(runTop, hS - 8)
nwY = math.max(run.h, hN - 8) nwY = math.max(runTop, hN - 8)
end end
if heightAt(tx + 1, ty) < run.h then -- east flank: hip if heightAt(tx + 1, ty) < runTop then -- east flank: hip
seY = math.max(run.h, hS - 8) seY = math.max(runTop, hS - 8)
neY = math.max(run.h, hN - 8) neY = math.max(runTop, hN - 8)
end end
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8) local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 }, push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
@@ -558,7 +580,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
break break
end end
end end
local row = math.min(ty, front - math.floor(h / 8)) local row = math.min(ty, front - math.floor(hLocal / 8))
if row < north then if row < north then
-- the whole run folded onto the face: top with the drawn -- the whole run folded onto the face: top with the drawn
-- row just above it when that row is furniture too (a -- row just above it when that row is furniture too (a
@@ -583,6 +605,14 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- sides: 8px bands wherever the neighbour is lower. Band k spans -- sides: 8px bands wherever the neighbour is lower. Band k spans
-- heights [8k, 8k+8) and shows one full tile of art; a partial -- heights [8k, 8k+8) and shows one full tile of art; a partial
-- band crops the art rows to match, so nothing ever stretches. -- band crops the art rows to match, so nothing ever stretches.
-- Bands count in CELL-LOCAL height (world minus this cell's base):
-- the fold starts at the cell's own feet wherever the terrain
-- raised them, and the crop a 6px lip face has always worn stays
-- byte-identical on a flat map. Negative bands are the SKIRT a
-- raised cell shows a lower neighbour -- terrain that had no face
-- at all before elevation -- and they wear the cell's own art
-- from its top row down, the same convention the recessed-water
-- shoreline bands established below zero.
for _, side in ipairs(SIDES) do for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2]) local nh = heightAt(tx + side[1], ty + side[2])
if nh < h then if nh < h then
@@ -593,9 +623,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
local lat = LATERAL[d] local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0 local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0 local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do local nl = nh - b
local y0 = math.max(nh, band * 8) for band = math.floor(nl / 8), math.ceil(hLocal / 8) - 1 do
local y1 = math.min(h, band * 8 + 8) local ly0 = math.max(nl, band * 8)
local ly1 = math.min(hLocal, band * 8 + 8)
local y0, y1 = b + ly0, b + ly1
if y1 > y0 then if y1 > y0 then
local src, shade = tile, Voxel3D.FACE_SHADE[d] local src, shade = tile, Voxel3D.FACE_SHADE[d]
if run then if run then
@@ -639,7 +671,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
end end
end end
sideQuad(d, x0, z0, y0, y1, src, sideQuad(d, x0, z0, y0, y1, src,
(band * 8 + 8) - y1, (band * 8 + 8) - y0, (band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
sideShades(hl, hr, y0, y1, y0 <= nh, shade)) sideShades(hl, hr, y0, y1, y0 <= nh, shade))
end end
end end
@@ -716,7 +748,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- the neighbour will ever draw that geometry -- the neighbour will ever draw that geometry
if q.own or outwardOnEdge(q, x0, z0, x1, z1) if q.own or outwardOnEdge(q, x0, z0, x1, z1)
or keepQuad(x0, z0, x1, z1) then or keepQuad(x0, z0, x1, z1) then
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade)) local fl = S.base
and baseAt(keyOf(math.floor(q[1][1] / 8),
math.floor(q[1][3] / 8))) or 0
push({ q[1], q[2], q[3], q[4] }, quadUV(q),
groundShades(q, q.shade, fl))
end end
end end
@@ -760,7 +796,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
for i = 1, 4 do for i = 1, 4 do
local c, s2 = q[i], sc[i] local c, s2 = q[i], sc[i]
s2[1] = c[1] + mx s2[1] = c[1] + mx
s2[2] = c[2] s2[2] = c[2] + (st.my or 0)
s2[3] = c[3] + mz s2[3] = c[3] + mz
end end
local ok = keepAll local ok = keepAll
@@ -772,7 +808,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
ok = keepQuad(x0, z0, x1, z1) ok = keepQuad(x0, z0, x1, z1)
end end
if ok then if ok then
push(sc, quadUV(q), groundShades(sc, q.shade)) push(sc, quadUV(q), groundShades(sc, q.shade, st.my))
end end
end end
end end
+6 -2
View File
@@ -29,6 +29,7 @@ local V = ...
local Mat4 = V.require("Mat4") local Mat4 = V.require("Mat4")
local Voxel = V.require("VoxelState") local Voxel = V.require("VoxelState")
local Elevation = V.require("Elevation")
local ShadowMap = {} local ShadowMap = {}
@@ -295,7 +296,10 @@ local function fit(cx, cy, vw, vh)
local f = sunDir() local f = sunDir()
local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 }) local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 })
local reach = ShadowMap.HEIGHT -- the tallest thing that can cast: the fixed geometry ceiling plus the
-- terrain base it may be standing on (0 wherever no elevation solved)
local top = ShadowMap.HEIGHT + Elevation.maxBase()
local reach = top
* math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24 * math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24
local north = groundReach(vh) local north = groundReach(vh)
-- the view widens with distance, so the far ground spans more than the -- the view widens with distance, so the far ground spans more than the
@@ -303,7 +307,7 @@ local function fit(cx, cy, vw, vh)
-- frustum's true spread and costs a good deal less resolution -- frustum's true spread and costs a good deal less resolution
local spread = north * 0.5 local spread = north * 0.5
local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach } local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach }
local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water local ys = { -32, top } -- -32 covers recessed water
local zs = { cy - north, cy + vh / 2 + reach } local zs = { cy - north, cy + vh / 2 + reach }
local l, r, b, t, zn, zf local l, r, b, t, zn, zf
+76 -23
View File
@@ -49,6 +49,7 @@ local Map = require("src.world.Map")
local Buildings = V.require("Buildings") local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape") local TileShape = V.require("TileShape")
local Budget = V.require("BuildBudget") local Budget = V.require("BuildBudget")
local Elevation = V.require("Elevation")
local Structures = {} local Structures = {}
@@ -208,6 +209,38 @@ function Structures.forMap(map)
end end
end end
-- ---- the terrain's base elevation under every tile ----
--
-- Elevation solves the ledge-bounded terrain once, globally, at CELL
-- granularity (see lib/Elevation.lua); here it lands per TILE so the
-- mesher and every quad emitter below read one table. Two wrinkles:
--
-- * a ledge tile's box KEEPS its authored height but sinks by it --
-- base + h then puts the lip's top flush with the high plateau it
-- is the rim of, and its exposed south face is exactly the tier
-- drop wearing the same cropped lip art it always wore;
-- * ring tiles read through baseAtTile's edge clamp, so the border
-- apron continues the body's elevation instead of cliffing to 0.
--
-- Maps without a field (every interior) get no table at all, and every
-- consumer's `S.base and ...` guard keeps the classic flat path.
local base = nil
if Elevation.fieldFor(map.id) then
base = {}
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local k = keyOf(tx, ty)
if tileAt[k] then
local b = Elevation.baseAtTile(map, tx, ty)
local s = shapeAt[k]
if s and s.class == "ledge" then b = b - (s.h or 0) end
if b ~= 0 then base[k] = b end
end
end
end
end
-- ---- buildings: whole sprites voxelized band by band ---- -- ---- buildings: whole sprites voxelized band by band ----
-- --
-- Before anything else looks at this grid. A profiled building is a -- Before anything else looks at this grid. A profiled building is a
@@ -222,7 +255,7 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over -- still overdraws a walker's feet even though characters stamp over
-- terrain.) -- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def), S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil, hideBareRing = hullRingOnly or nil, base = base,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {}, runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} } grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow) Buildings.build(S, map, pixels(tileset), perRow)
@@ -1002,7 +1035,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
ground = tpl.bg or false ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] = S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16, { quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
r = 16 } r = 16, my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
end end
for dy = 0, 3 do for dy = 0, 3 do
for dx = 0, 3 do for dx = 0, 3 do
@@ -1035,7 +1068,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
end end
ground = tpl.bg or false ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] = S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 } { quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8,
my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
end end
-- headless (no pixels): no hull, but still claim the tiles so -- headless (no pixels): no hull, but still claim the tiles so
-- the volume path never boxes a pinned cell. Ground is the -- the volume path never boxes a pinned cell. Ground is the
@@ -1135,6 +1169,8 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
local quads = S.objectQuads local quads = S.objectQuads
local wx0, wz0 = region.minX * 8, region.minY * 8 local wx0, wz0 = region.minX * 8, region.minY * 8
-- a relief lies ON the terrain, so the whole slab rides the region's base
local by = S.base and S.base[keyOf(region.minX, region.minY)] or 0
for py = 0, bh - 1 do for py = 0, bh - 1 do
for px = 0, bw - 1 do for px = 0, bw - 1 do
if on(px, py) then if on(px, py) then
@@ -1142,26 +1178,27 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
local u = (srcU[i] + 0.5) / atlasW local u = (srcU[i] + 0.5) / atlasW
local v = (srcV[i] + 0.5) / atlasH local v = (srcV[i] + 0.5) / atlasH
local x, z = wx0 + px, wz0 + py local x, z = wx0 + px, wz0 + py
local y0, y1 = by, by + h
local function quad(c1, c2, c3, c4, shade) local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end end
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 }, quad({ x, y1, z }, { x + 1, y1, z }, { x + 1, y1, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.top) { x, y1, z + 1 }, RELIEF_SHADE.top)
if not on(px, py + 1) then if not on(px, py + 1) then
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 }, quad({ x, y0, z + 1 }, { x + 1, y0, z + 1 }, { x + 1, y1, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.south) { x, y1, z + 1 }, RELIEF_SHADE.south)
end end
if not on(px, py - 1) then if not on(px, py - 1) then
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z }, quad({ x + 1, y0, z }, { x, y0, z }, { x, y1, z },
{ x + 1, h, z }, RELIEF_SHADE.north) { x + 1, y1, z }, RELIEF_SHADE.north)
end end
if not on(px - 1, py) then if not on(px - 1, py) then
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 }, quad({ x, y0, z }, { x, y0, z + 1 }, { x, y1, z + 1 },
{ x, h, z }, RELIEF_SHADE.side) { x, y1, z }, RELIEF_SHADE.side)
end end
if not on(px + 1, py) then if not on(px + 1, py) then
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z }, quad({ x + 1, y0, z + 1 }, { x + 1, y0, z }, { x + 1, y1, z },
{ x + 1, h, z + 1 }, RELIEF_SHADE.side) { x + 1, y1, z + 1 }, RELIEF_SHADE.side)
end end
end end
end end
@@ -1208,10 +1245,11 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
return ns ~= nil and ns.art == "bookcase" return ns ~= nil and ns.art == "bookcase"
end end
local by = S.base and S.base[keyOf(tx, frontTy)] or 0
for band = 0, bands - 1 do for band = 0, bands - 1 do
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
local u0, u1, v0, v1 = uvRect(tile) local u0, u1, v0, v1 = uvRect(tile)
local y0, y1 = band * 8, band * 8 + 8 local y0, y1 = by + band * 8, by + band * 8 + 8
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 }, quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
{ x1, y1, z1 }, { x0, y1, z1 }, { x1, y1, z1 }, { x0, y1, z1 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
@@ -1330,6 +1368,7 @@ local function stairCell(S, map, data, cx, cy, s)
local atlasW = map.tileset.imageWidth or 128 local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48 local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads local quads = S.objectQuads
local q0 = #quads
local down = s.class == "stair_down_e" or s.class == "stair_down_w" local down = s.class == "stair_down_e" or s.class == "stair_down_w"
local east = s.class == "stair_e" or s.class == "stair_down_e" local east = s.class == "stair_e" or s.class == "stair_down_e"
local mx, mz = cx * 16, cy * 16 local mx, mz = cx * 16, cy * 16
@@ -1466,6 +1505,16 @@ local function stairCell(S, map, data, cx, cy, s)
end end
end end
end end
-- the whole flight was built from y=0; a raised base lifts it after
-- the fact so the geometry above stays in the cell's own space
local by = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0
if by ~= 0 then
for i = q0 + 1, #quads do
local q = quads[i]
for c = 1, 4 do q[c][2] = q[c][2] + by end
end
end
end end
function Structures.buildStairs(S, map, x0, x1, y0, y1) function Structures.buildStairs(S, map, x0, x1, y0, y1)
@@ -2028,6 +2077,8 @@ function Structures.buildObject(S, map, region, cluster,
baseY, support = bs.h, bs baseY, support = bs.h, bs
end end
end end
-- and whatever it stands on, it stands on it at the terrain's base
baseY = baseY + (S.base and S.base[keyOf(cluster.minX, cluster.maxY)] or 0)
local atlasW = map.tileset.imageWidth or 128 local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48 local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads local quads = S.objectQuads
@@ -2291,7 +2342,7 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
quads = quads, quads = quads,
wx = tx * 8 + minX, wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4, wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY, y = baseY + (S.base and S.base[keyOf(tx, ty + fig.h - 1)] or 0),
} }
-- What each covered tile wears now that he is off it. Only the ART -- What each covered tile wears now that he is off it. Only the ART
@@ -2436,12 +2487,13 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
templates[tileId] = tpl templates[tileId] = tpl
end end
local wx, wz = tx * 8, ty * 8 local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do for _, q in ipairs(tpl) do
quads[#quads + 1] = { quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz }, { q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz }, { q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz }, { q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz }, { q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade, uv = q.uv, shade = q.shade,
} }
end end
@@ -2635,12 +2687,13 @@ function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
templates[tileId] = tpl templates[tileId] = tpl
end end
local wx, wz = tx * 8, ty * 8 local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do for _, q in ipairs(tpl) do
quads[#quads + 1] = { quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz }, { q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz }, { q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz }, { q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz }, { q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade, uv = q.uv, shade = q.shade,
} }
end end
+7 -2
View File
@@ -509,8 +509,13 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
local fov = 2 * math.atan(1 / (2 * focal)) local fov = 2 * math.atan(1 / (2 * focal))
Voxel3D.fovY = fov Voxel3D.fovY = fov
local focus = { cx, 0, cy } -- The height the orbit looks AT -- the smoothed ground under the
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- player's feet (VoxelScene tracks it), so climbing a terrace does not
-- slide the walker up the screen at a pitched camera. Zero on flat
-- terrain, which is the framing this rig always had.
local fy = Voxel3D.focusY or 0
local focus = { cx, fy, cy }
local eye = { cx, fy + dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it) -- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye Voxel3D.eye = eye
Voxel3D.focus = focus Voxel3D.focus = focus
+32 -7
View File
@@ -24,11 +24,15 @@ local Sky = V.require("Sky")
local Water = V.require("Water") local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid") local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight") local DayNight = V.require("DayNight")
local Elevation = V.require("Elevation")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local VoxelScene = {} local VoxelScene = {}
-- the camera's smoothed focus height (see render); nil until first framed
local focusHeight = nil
-- What the active display mode actually paints with. -- What the active display mode actually paints with.
-- --
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what -- paletteFor hands back a map's RAW SGB zone palette, and that is not what
@@ -166,24 +170,33 @@ local YAW = {
-- top of it rather than sunk into it. Uses the same bottom-left collision -- top of it rather than sunk into it. Uses the same bottom-left collision
-- tile the engine walks on (Map:cellTile). -- tile the engine walks on (Map:cellTile).
local function groundAt(map, cellX, cellY) local function groundAt(map, cellX, cellY)
-- The terrain's base under the cell -- 0 wherever no elevation field
-- exists (every interior), so the flat world keeps its old answers.
local b = Elevation.baseAt(map, cellX, cellY)
-- Off the map, cellTile border-extends into the map's borderBlock -- -- Off the map, cellTile border-extends into the map's borderBlock --
-- which on maps ringed with trees is a RAISED tile. The only entity -- which on maps ringed with trees is a RAISED tile. The only entity
-- ever standing off-map is the player mid seam-step (placed one cell -- ever standing off-map is the player mid seam-step (placed one cell
-- before the connection entry), and the ground actually rendered -- before the connection entry), and the ground actually rendered
-- there is the departed neighbour's flat walkway: height 0. Without -- there is the departed neighbour's flat walkway: the border apron's
-- this, crossing into such a map hoisted the walker tree-high for -- own base (baseAt clamps to the nearest body cell, which is how the
-- exactly one step -- the "hops like a ledge" seam bug. -- apron is meshed). Without this, crossing into such a map hoisted
if not map:inBounds(cellX, cellY) then return 0 end -- the walker tree-high for exactly one step -- the "hops like a
-- ledge" seam bug.
if not map:inBounds(cellX, cellY) then return b end
local shapes = TileShape.forMap(map) local shapes = TileShape.forMap(map)
local s = shapes[map:cellTile(cellX, cellY)] local s = shapes[map:cellTile(cellX, cellY)]
if not s then return 0 end if not s then return b end
-- a recessed class (water) still supports whatever stands on it; only -- a recessed class (water) still supports whatever stands on it; only
-- raised ground lifts the model. Stairs never do: the class height is -- raised ground lifts the model. Stairs never do: the class height is
-- the flight's TALL end, but the player enters at floor level and the -- the flight's TALL end, but the player enters at floor level and the
-- warp fires as they step in -- lifting them onto the geometry read as -- warp fires as they step in -- lifting them onto the geometry read as
-- climbing an invisible block -- climbing an invisible block
if s.art == "stair" then return 0 end if s.art == "stair" then return b end
return s.h > 0 and s.h or 0 -- on solved terrain a ledge is the high plateau's rim, its lip flush
-- with the ground it belongs to: the solver's base IS its top. On a
-- flat map it is still the classic 6px bump you stand on top of.
if s.class == "ledge" and Elevation.fieldFor(map.id) then return b end
return b + (s.h > 0 and s.h or 0)
end end
VoxelScene.YAW = YAW VoxelScene.YAW = YAW
@@ -776,6 +789,18 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
water, nbWater) water, nbWater)
-- The camera's focus height chases the ground under the player's feet,
-- eased so a 2px terrace tread is a glide rather than a pop; a WARP-
-- sized jump (raised route -> interior at 0) snaps instead of swooping
-- the whole frame through the floor.
local targetY = me and me.gh or 0
if focusHeight == nil or math.abs(targetY - focusHeight) > 24 then
focusHeight = targetY
else
focusHeight = focusHeight + (targetY - focusHeight) * 0.12
end
Voxel3D.focusY = focusHeight
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil return nil
end end
+17
View File
@@ -700,6 +700,23 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end if mapId then ChunkMesher.invalidate(mapId) end
end) end)
-- ------- the terrain solver needs the whole map registry
--
-- lib/Elevation.lua cuts the connected overworld into plateaus, which
-- takes every map's blocks and connections at once -- not just the one
-- being walked. The engine keeps that registry in main.lua's `Game`,
-- which is a LOCAL there and reachable from no mod, so it arrives here
-- instead: `mods.loaded` carries the merged dataset, and it is the only
-- moment the whole of it is handed over. Without this the solver found
-- no data, answered "no field" for every map, and the world stayed as
-- flat as it ever was -- silently, which is the part that cost a while.
mod.events:on("mods.loaded", function(payload)
local data = payload and payload.data
if data and data.maps then
V.require("Elevation").install(data)
end
end)
-- ------- rows come and go, so the menu has to notice -- ------- rows come and go, so the menu has to notice
-- --
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads -- OptionsMenu builds its row list ONCE, when it is opened, and then reads