diff --git a/CHANGELOG.md b/CHANGELOG.md index 172bfd4..9674882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ # 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 meet itself + across its own ledge line and contradict. Instead a least-squares field + is solved over the whole connected overworld at once (lib/Elevation.lua): + every ledge is a hard one-tier step with the direction the game's own + hop table gives it, every body of water is hard-tied to one level, and + everywhere else adjacent cells prefer to be level -- so the missing tier + through a bypass gap is paid off as a gentle ramp of 2px terrace treads, + and far from any ledge the rounding snaps the world flat. The solve is + global and anchored at Pallet, so two connected maps never disagree + about a seam; it runs once, inside the build budget, in ~0.15s for all + 43k cells of Kanto, with zero contradictions in the shipped ledge data. + + 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 diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 4079a53..a501a5f 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -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, diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index 20f0e04..dc4a53b 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -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 diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 7bfc6e0..a15fe97 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -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 diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 73ed890..d5b182a 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -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 diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 18ceffd..b94e760 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -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 diff --git a/lib/Structures.lua b/lib/Structures.lua index 0b5262a..0ddfdb1 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -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 diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 471a688..2586681 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -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 diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index daa4131..0c624d9 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -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