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
## 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
+4
View File
@@ -98,6 +98,10 @@ return {
ground = 0,
water = -2,
void = 0,
-- doubles as the terrain TIER: on the connected overworld the ground
-- above a hop-down edge stands this many pixels up (lib/Elevation.lua)
-- and the lip's box sinks by the same amount to sit flush as its rim,
-- so retuning it retunes the cliffs with the faces that clothe them
ledge = 6,
fence = 10,
sign = 12,
+8 -2
View File
@@ -242,13 +242,19 @@ end
-- Whether both mons would be in plain view from the battle camera.
function BattleArena.clearance(map, arena)
local BattleCam = V.require("BattleCam")
local ok, rig = pcall(BattleCam.rig, arena, 0)
-- 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
local eye = rig.eye
local H = BattleArena.MON_H
for _, mark in ipairs({ arena.player, arena.enemy }) do
for _, hy in ipairs({ 1, H * 0.5, H }) do
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
if not lineClear(map, eye, mark[1], gy + hy, mark[2]) then
return false
end
end
end
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)
end
-- One building, ONE base: the model is rigid, so it stands at the
-- elevation under its door row and the pad beneath is flattened to
-- match -- a house near a ramp must not have terraced floorboards.
local my = S.base
and S.base[keyOf(tx + math.floor(bw / 2), ty + bh - 1)] or 0
for r = 0, bh - 1 do
for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r)
S.shapeAt[k] = shape
S.skip[k] = true
S.ground[k] = best or false
if S.base then S.base[k] = my ~= 0 and my or nil end
end
end
@@ -714,10 +721,10 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
local out = S.objectQuads
for _, q in ipairs(quads) do
out[#out + 1] = {
{ q[1][1] + mx, q[1][2], q[1][3] + mz },
{ q[2][1] + mx, q[2][2], q[2][3] + mz },
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
{ q[1][1] + mx, q[1][2] + my, q[1][3] + mz },
{ q[2][1] + mx, q[2][2] + my, q[2][3] + mz },
{ q[3][1] + mx, q[3][2] + my, q[3][3] + mz },
{ q[4][1] + mx, q[4][2] + my, q[4][3] + mz },
uv = q.uv, shade = q.shade,
-- placements only ever scan the BODY, so a building is always this
-- map's own structure: the mesher's edge keep-rules must not eat
+69 -33
View File
@@ -239,13 +239,22 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
local atlasW = tileset.imageWidth or (perRow * 8)
local atlasH = tileset.imageHeight or 48
-- The terrain's base elevation under a tile (0 on a map without a
-- solved field -- every interior), and the ABSOLUTE height of what
-- stands there: base + the shape's own extrusion. Side faces are
-- derived from neighbour height differences, so once every height is
-- measured from the same datum the cliff skirt under a raised cell
-- falls out of the same band loop that has always clothed walls.
local baseAt = S.base and function(k) return S.base[k] or 0 end
or function() return 0 end
local function heightAt(tx, ty)
local k = keyOf(tx, ty)
if S.skip[k] then return 0 end
if S.skip[k] then return baseAt(k) end
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]
return s and s.h or 0
return baseAt(k) + (s and s.h or 0)
end
-- 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
-- light reaches it -- which is what plants a prop on the floor instead
-- of leaving it looking pasted over the top.
-- `floor` is the terrain base the prop stands on: contact darkening
-- measures height above the prop's OWN ground, not above the world
-- datum, or every plant on a plateau would lose its feet.
local aoProp = { 0, 0, 0, 0 }
local function groundShades(c, shade)
local function groundShades(c, shade, floor)
if type(shade) == "table" then return shade end
floor = floor or 0
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
if math.min(y1, y2, y3, y4) >= 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
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)))
end
return aoProp
@@ -456,28 +469,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- prebuilt prism quads (appended below) carry the art
local g = S.ground[k]
if g then
topQuad(tx * 8, ty * 8, 0, g, 1)
-- the claimed tile is still ground at height 0, and water next
local b = baseAt(k)
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
-- side bands ordinary ground emits, the two-pixel shoreline
-- face is a slit into the sky behind the mesh -- which is
-- exactly what a building plot or a sign standing at the
-- waterline showed. Same bands, cut from the synthesized
-- ground's own art
-- 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
local nh = heightAt(tx + side[1], ty + side[2])
if nh < 0 then
if nh < b then
local d = side[3]
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), -1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(0, band * 8 + 8)
if y1 > y0 then
sideQuad(d, tx * 8, ty * 8, y0, y1, g,
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
sideShades(hl, hr, y0, y1, y0 <= nh,
local nl = nh - b
for band = math.floor(nl / 8), -1 do
local ly0 = math.max(nl, band * 8)
local ly1 = math.min(0, band * 8 + 8)
if ly1 > ly0 then
sideQuad(d, tx * 8, ty * 8, b + ly0, b + ly1, g,
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
sideShades(hl, hr, b + ly0, b + ly1, ly0 <= nl,
Voxel3D.FACE_SHADE[d]))
end
end
@@ -486,7 +503,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
end
elseif s then
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
-- 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.
if run and run.rise > 0 then
local mid = run.extent / 2
local runTop = b + run.h -- the facade top, absolute
local function gableH(d) -- d = rows north of the south eave
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
return run.h + run.rise * math.max(0, math.min(1, t))
return runTop + run.rise * math.max(0, math.min(1, t))
end
local d0 = run.front - ty -- rows from the south edge
local hS = gableH(d0)
@@ -515,13 +537,13 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
math.floor((1 - rel) * run.roofRows))
local roofTile = map:tileAt(tx, run.north + idx)
local swY, seY, neY, nwY = hS, hS, hN, hN
if heightAt(tx - 1, ty) < run.h then -- west flank: hip
swY = math.max(run.h, hS - 8)
nwY = math.max(run.h, hN - 8)
if heightAt(tx - 1, ty) < runTop then -- west flank: hip
swY = math.max(runTop, hS - 8)
nwY = math.max(runTop, hN - 8)
end
if heightAt(tx + 1, ty) < run.h then -- east flank: hip
seY = math.max(run.h, hS - 8)
neY = math.max(run.h, hN - 8)
if heightAt(tx + 1, ty) < runTop then -- east flank: hip
seY = math.max(runTop, hS - 8)
neY = math.max(runTop, hN - 8)
end
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
@@ -558,7 +580,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
break
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
-- the whole run folded onto the face: top with the drawn
-- 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
-- heights [8k, 8k+8) and shows one full tile of art; a partial
-- band crops the art rows to match, so nothing ever stretches.
-- Bands count in CELL-LOCAL height (world minus this cell's base):
-- the fold starts at the cell's own feet wherever the terrain
-- raised them, and the crop a 6px lip face has always worn stays
-- byte-identical on a flat map. Negative bands are the SKIRT a
-- raised cell shows a lower neighbour -- terrain that had no face
-- at all before elevation -- and they wear the cell's own art
-- from its top row down, the same convention the recessed-water
-- shoreline bands established below zero.
for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2])
if nh < h then
@@ -593,9 +623,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(h, band * 8 + 8)
local nl = nh - b
for band = math.floor(nl / 8), math.ceil(hLocal / 8) - 1 do
local ly0 = math.max(nl, band * 8)
local ly1 = math.min(hLocal, band * 8 + 8)
local y0, y1 = b + ly0, b + ly1
if y1 > y0 then
local src, shade = tile, Voxel3D.FACE_SHADE[d]
if run then
@@ -639,7 +671,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
end
end
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))
end
end
@@ -716,7 +748,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- the neighbour will ever draw that geometry
if q.own or outwardOnEdge(q, x0, z0, x1, z1)
or keepQuad(x0, z0, x1, z1) then
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
@@ -760,7 +796,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
for i = 1, 4 do
local c, s2 = q[i], sc[i]
s2[1] = c[1] + mx
s2[2] = c[2]
s2[2] = c[2] + (st.my or 0)
s2[3] = c[3] + mz
end
local ok = keepAll
@@ -772,7 +808,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
ok = keepQuad(x0, z0, x1, z1)
end
if ok then
push(sc, quadUV(q), groundShades(sc, q.shade))
push(sc, quadUV(q), groundShades(sc, q.shade, st.my))
end
end
end
+6 -2
View File
@@ -29,6 +29,7 @@ local V = ...
local Mat4 = V.require("Mat4")
local Voxel = V.require("VoxelState")
local Elevation = V.require("Elevation")
local ShadowMap = {}
@@ -295,7 +296,10 @@ local function fit(cx, cy, vw, vh)
local f = sunDir()
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
local north = groundReach(vh)
-- 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
local spread = north * 0.5
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 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 TileShape = V.require("TileShape")
local Budget = V.require("BuildBudget")
local Elevation = V.require("Elevation")
local Structures = {}
@@ -208,6 +209,38 @@ function Structures.forMap(map)
end
end
-- ---- the terrain's base elevation under every tile ----
--
-- Elevation solves the ledge-bounded terrain once, globally, at CELL
-- granularity (see lib/Elevation.lua); here it lands per TILE so the
-- mesher and every quad emitter below read one table. Two wrinkles:
--
-- * a ledge tile's box KEEPS its authored height but sinks by it --
-- base + h then puts the lip's top flush with the high plateau it
-- is the rim of, and its exposed south face is exactly the tier
-- drop wearing the same cropped lip art it always wore;
-- * ring tiles read through baseAtTile's edge clamp, so the border
-- apron continues the body's elevation instead of cliffing to 0.
--
-- Maps without a field (every interior) get no table at all, and every
-- consumer's `S.base and ...` guard keeps the classic flat path.
local base = nil
if Elevation.fieldFor(map.id) then
base = {}
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local k = keyOf(tx, ty)
if tileAt[k] then
local b = Elevation.baseAtTile(map, tx, ty)
local s = shapeAt[k]
if s and s.class == "ledge" then b = b - (s.h or 0) end
if b ~= 0 then base[k] = b end
end
end
end
end
-- ---- buildings: whole sprites voxelized band by band ----
--
-- Before anything else looks at this grid. A profiled building is a
@@ -222,7 +255,7 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil,
hideBareRing = hullRingOnly or nil, base = base,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
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
S.roundStamps[#S.roundStamps + 1] =
{ 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
for dy = 0, 3 do
for dx = 0, 3 do
@@ -1035,7 +1068,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
end
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
{ 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
-- headless (no pixels): no hull, but still claim the tiles so
-- 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 wx0, wz0 = region.minX * 8, region.minY * 8
-- a relief lies ON the terrain, so the whole slab rides the region's base
local by = S.base and S.base[keyOf(region.minX, region.minY)] or 0
for py = 0, bh - 1 do
for px = 0, bw - 1 do
if on(px, py) then
@@ -1142,26 +1178,27 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
local u = (srcU[i] + 0.5) / atlasW
local v = (srcV[i] + 0.5) / atlasH
local x, z = wx0 + px, wz0 + py
local y0, y1 = by, by + h
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.top)
quad({ x, y1, z }, { x + 1, y1, z }, { x + 1, y1, z + 1 },
{ x, y1, z + 1 }, RELIEF_SHADE.top)
if not on(px, py + 1) then
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.south)
quad({ x, y0, z + 1 }, { x + 1, y0, z + 1 }, { x + 1, y1, z + 1 },
{ x, y1, z + 1 }, RELIEF_SHADE.south)
end
if not on(px, py - 1) then
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
{ x + 1, h, z }, RELIEF_SHADE.north)
quad({ x + 1, y0, z }, { x, y0, z }, { x, y1, z },
{ x + 1, y1, z }, RELIEF_SHADE.north)
end
if not on(px - 1, py) then
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
{ x, h, z }, RELIEF_SHADE.side)
quad({ x, y0, z }, { x, y0, z + 1 }, { x, y1, z + 1 },
{ x, y1, z }, RELIEF_SHADE.side)
end
if not on(px + 1, py) then
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
quad({ x + 1, y0, z + 1 }, { x + 1, y0, z }, { x + 1, y1, z },
{ x + 1, y1, z + 1 }, RELIEF_SHADE.side)
end
end
end
@@ -1208,10 +1245,11 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
return ns ~= nil and ns.art == "bookcase"
end
local by = S.base and S.base[keyOf(tx, frontTy)] or 0
for band = 0, bands - 1 do
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
local u0, u1, v0, v1 = uvRect(tile)
local y0, y1 = band * 8, band * 8 + 8
local y0, y1 = by + band * 8, by + band * 8 + 8
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
{ x1, y1, z1 }, { x0, y1, z1 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
@@ -1330,6 +1368,7 @@ local function stairCell(S, map, data, cx, cy, s)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
local q0 = #quads
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
local east = s.class == "stair_e" or s.class == "stair_down_e"
local mx, mz = cx * 16, cy * 16
@@ -1466,6 +1505,16 @@ local function stairCell(S, map, data, cx, cy, s)
end
end
end
-- the whole flight was built from y=0; a raised base lifts it after
-- the fact so the geometry above stays in the cell's own space
local by = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0
if by ~= 0 then
for i = q0 + 1, #quads do
local q = quads[i]
for c = 1, 4 do q[c][2] = q[c][2] + by end
end
end
end
function Structures.buildStairs(S, map, x0, x1, y0, y1)
@@ -2028,6 +2077,8 @@ function Structures.buildObject(S, map, region, cluster,
baseY, support = bs.h, bs
end
end
-- and whatever it stands on, it stands on it at the terrain's base
baseY = baseY + (S.base and S.base[keyOf(cluster.minX, cluster.maxY)] or 0)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
@@ -2291,7 +2342,7 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY,
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
@@ -2436,12 +2487,13 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
@@ -2635,12 +2687,13 @@ function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
+7 -2
View File
@@ -509,8 +509,13 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
local fov = 2 * math.atan(1 / (2 * focal))
Voxel3D.fovY = fov
local focus = { cx, 0, cy }
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
-- The height the orbit looks AT -- the smoothed ground under the
-- player's feet (VoxelScene tracks it), so climbing a terrace does not
-- slide the walker up the screen at a pitched camera. Zero on flat
-- terrain, which is the framing this rig always had.
local fy = Voxel3D.focusY or 0
local focus = { cx, fy, cy }
local eye = { cx, fy + dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye
Voxel3D.focus = focus
+32 -7
View File
@@ -24,11 +24,15 @@ local Sky = V.require("Sky")
local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local Elevation = V.require("Elevation")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local VoxelScene = {}
-- the camera's smoothed focus height (see render); nil until first framed
local focusHeight = nil
-- What the active display mode actually paints with.
--
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
@@ -166,24 +170,33 @@ local YAW = {
-- top of it rather than sunk into it. Uses the same bottom-left collision
-- tile the engine walks on (Map:cellTile).
local function groundAt(map, cellX, cellY)
-- The terrain's base under the cell -- 0 wherever no elevation field
-- exists (every interior), so the flat world keeps its old answers.
local b = Elevation.baseAt(map, cellX, cellY)
-- Off the map, cellTile border-extends into the map's borderBlock --
-- which on maps ringed with trees is a RAISED tile. The only entity
-- ever standing off-map is the player mid seam-step (placed one cell
-- before the connection entry), and the ground actually rendered
-- there is the departed neighbour's flat walkway: height 0. Without
-- this, crossing into such a map hoisted the walker tree-high for
-- exactly one step -- the "hops like a ledge" seam bug.
if not map:inBounds(cellX, cellY) then return 0 end
-- there is the departed neighbour's flat walkway: the border apron's
-- own base (baseAt clamps to the nearest body cell, which is how the
-- apron is meshed). Without this, crossing into such a map hoisted
-- the walker tree-high for exactly one step -- the "hops like a
-- ledge" seam bug.
if not map:inBounds(cellX, cellY) then return b end
local shapes = TileShape.forMap(map)
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
-- raised ground lifts the model. Stairs never do: the class height is
-- the flight's TALL end, but the player enters at floor level and the
-- warp fires as they step in -- lifting them onto the geometry read as
-- climbing an invisible block
if s.art == "stair" then return 0 end
return s.h > 0 and s.h or 0
if s.art == "stair" then return b end
-- on solved terrain a ledge is the high plateau's rim, its lip flush
-- with the ground it belongs to: the solver's base IS its top. On a
-- flat map it is still the classic 6px bump you stand on top of.
if s.class == "ledge" and Elevation.fieldFor(map.id) then return b end
return b + (s.h > 0 and s.h or 0)
end
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,
water, nbWater)
-- The camera's focus height chases the ground under the player's feet,
-- eased so a 2px terrace tread is a glide rather than a pop; a WARP-
-- sized jump (raised route -> interior at 0) snaps instead of swooping
-- the whole frame through the floor.
local targetY = me and me.gh or 0
if focusHeight == nil or math.abs(targetY - focusHeight) > 24 then
focusHeight = targetY
else
focusHeight = focusHeight + (targetY - focusHeight) * 0.12
end
Voxel3D.focusY = focusHeight
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
end
+17
View File
@@ -700,6 +700,23 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
-- ------- the terrain solver needs the whole map registry
--
-- lib/Elevation.lua cuts the connected overworld into plateaus, which
-- takes every map's blocks and connections at once -- not just the one
-- being walked. The engine keeps that registry in main.lua's `Game`,
-- which is a LOCAL there and reachable from no mod, so it arrives here
-- instead: `mods.loaded` carries the merged dataset, and it is the only
-- moment the whole of it is handed over. Without this the solver found
-- no data, answered "no field" for every map, and the world stayed as
-- flat as it ever was -- silently, which is the part that cost a while.
mod.events:on("mods.loaded", function(payload)
local data = payload and payload.data
if data and data.maps then
V.require("Elevation").install(data)
end
end)
-- ------- rows come and go, so the menu has to notice
--
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads