From 906967dd48198ed72f8cd5b1e4bb87152841d5ef Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 29 Jul 2026 07:03:58 -0400 Subject: [PATCH] Sweep camera into and out of voxel battles --- lib/BattleScene.lua | 52 ++++++++++++++++-- lib/BattleSweep.lua | 72 +++++++++++++++++++++++++ lib/OverworldBattle.lua | 104 +++++++++++++++++++++++++++++++++--- main.lua | 2 +- manifest.json | 4 +- mod.card | 1 + tests/battle_sweep_test.lua | 42 +++++++++++++++ 7 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 lib/BattleSweep.lua create mode 100644 tests/battle_sweep_test.lua diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 43c6003..9068e0f 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -41,6 +41,8 @@ local VoxelScene = V.require("VoxelScene") local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") +local Voxel = V.require("VoxelState") +local WorldCurve = V.require("WorldCurve") local PaletteFX = require("src.render.PaletteFX") local BattleScene = {} @@ -289,7 +291,48 @@ end BattleScene.FLASH_COLOR = { 1, 1, 1 } BattleScene.FLASH_STRENGTH = 0.5 -function BattleScene.render(state, arena, textures, token) +local function pitchFor(cam) + local ex = cam.eye[1] - cam.focus[1] + local ey = cam.eye[2] - cam.focus[2] + local ez = cam.eye[3] - cam.focus[3] + return math.atan2(math.sqrt(ex * ex + ez * ez), math.max(1e-3, ey)) +end + +-- The exact placed-camera equivalent of Voxel3D's ordinary orbit. Captured +-- when the encounter begins, this is what lets a transition start on the +-- frame the player was actually looking at instead of on a canned overview. +function BattleScene.freeCamera(state) + if not (state and state.camera) then return nil end + local renderer = require("src.core.Game").renderer + local vw, vh = 160, 144 + if renderer and renderer.worldViewSize then + local ok, rw, rh = pcall(renderer.worldViewSize, renderer) + if ok and rw and rh and rw > 0 and rh > 0 then vw, vh = rw, rh end + end + local cx = state.camera.x + vw / 2 + local cy = state.camera.y + vh / 2 + local dist = Voxel.FOCAL * vh + local angle = Voxel.angle or 0 + return { + eye = { cx, dist * math.cos(angle), cy + dist * math.sin(angle) }, + focus = { cx, 0, cy }, + fov = 2 * math.atan(1 / (2 * Voxel.FOCAL)), + curve = WorldCurve.k(vh), + } +end + +function BattleScene.battleCamera(state, arena) + if not (state and state.map and arena) then return nil end + local host = arena.map or state.map + local groundY = BattleScene.groundY(host, arena) + local cam = BattleCam.rig(arena, groundY) + local _, _, s, _, ph = BattleScene.letterbox() + if not (ph and ph > 0 and s and s > 0) then return nil end + cam.fov = BattleScene.letterboxFov(cam.fov, ph, s) + return cam +end + +function BattleScene.render(state, arena, textures, token, cameraOverride) if not (state and state.map and arena) then return nil end if not Voxel3D.available() then return nil end @@ -312,8 +355,11 @@ function BattleScene.render(state, arena, textures, token) end local groundY = BattleScene.groundY(host, arena) - local cam, pitch = BattleCam.rig(arena, groundY) - cam.fov = BattleScene.letterboxFov(cam.fov, ph, s) + local cam = cameraOverride or BattleCam.rig(arena, groundY) + if not cameraOverride then + cam.fov = BattleScene.letterboxFov(cam.fov, ph, s) + end + local pitch = pitchFor(cam) local cx, cy = arena.mid[1], arena.mid[2] -- the world extents the sun frustum is fitted to; the camera itself is diff --git a/lib/BattleSweep.lua b/lib/BattleSweep.lua new file mode 100644 index 0000000..59455e0 --- /dev/null +++ b/lib/BattleSweep.lua @@ -0,0 +1,72 @@ +-- A camera move between the walking diorama and the staged battle rig. +-- +-- This module is intentionally pure. Battle orchestration decides when the +-- move begins and ends; this only supplies a smooth camera for a normalized +-- time, which keeps the visual motion independently testable. + +local BattleSweep = {} + +BattleSweep.DURATION = 0.7 + +local function clamp01(t) + return math.max(0, math.min(1, tonumber(t) or 0)) +end + +function BattleSweep.ease(t) + t = clamp01(t) + -- Smootherstep has zero velocity and acceleration at both ends, so neither + -- the overworld camera nor the battle camera gets a visible kick. + return t * t * t * (t * (t * 6 - 15) + 10) +end + +local function lerp(a, b, t) + return (tonumber(a) or 0) + ((tonumber(b) or 0) - (tonumber(a) or 0)) * t +end + +local function vector(a, b, t) + return { + lerp(a and a[1], b and b[1], t), + lerp(a and a[2], b and b[2], t), + lerp(a and a[3], b and b[3], t), + } +end + +function BattleSweep.camera(from, to, t) + if not from then return to end + if not to then return from end + local e = BattleSweep.ease(t) + local eye = vector(from.eye, to.eye, e) + local focus = vector(from.focus, to.focus, e) + + -- A straight lerp feels like a zoom. A restrained sideways arc makes it + -- read as the camera sweeping around the scene while a small lift keeps + -- terrain from filling the lens halfway through. Both return exactly to + -- zero at the endpoints. + local dx = (to.eye[1] or 0) - (from.eye[1] or 0) + local dy = (to.eye[2] or 0) - (from.eye[2] or 0) + local dz = (to.eye[3] or 0) - (from.eye[3] or 0) + local horiz = math.sqrt(dx * dx + dz * dz) + local distance = math.sqrt(dx * dx + dy * dy + dz * dz) + local arc = math.sin(math.pi * e) + if horiz > 1e-4 then + local side = math.min(24, horiz * 0.12) * arc + eye[1] = eye[1] - dz / horiz * side + eye[3] = eye[3] + dx / horiz * side + end + eye[2] = eye[2] + math.min(48, distance * 0.1) * arc + + -- Interpolate the visible half-height rather than the angle itself. It + -- prevents the narrow battle lens from rushing in at the end. + local fromTan = math.tan((from.fov or math.rad(45)) / 2) + local toTan = math.tan((to.fov or math.rad(45)) / 2) + local fov = 2 * math.atan(lerp(fromTan, toTan, e)) + + return { + eye = eye, + focus = focus, + fov = fov, + curve = lerp(from.curve, to.curve, e), + } +end + +return BattleSweep diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f3998f0..0dfe14c 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -42,6 +42,7 @@ local ModSetting = V.require("ModSetting") local BattleArena = V.require("BattleArena") local BattleCam = V.require("BattleCam") local BattleScene = V.require("BattleScene") +local BattleSweep = V.require("BattleSweep") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") local BattlePics = V.require("BattlePics") @@ -189,8 +190,11 @@ function OverworldBattle.begin(state, battle) state.player.surfing) if not (ok and arena) then return false end - session = { state = state, arena = arena, battle = battle, shot = nil, - armed = false, token = 0 } + session = { + state = state, arena = arena, battle = battle, shot = nil, + armed = false, token = 0, phase = "waiting", transitionT = 0, + freeCamera = BattleScene.freeCamera(state), + } cullCast(state) BattleCam.reset() return true @@ -225,6 +229,28 @@ function OverworldBattle.finish() Voxel3D.camera = nil end +function OverworldBattle.transitioning(battle) + return session and not session.broken and session.battle == battle + and (session.phase == "waiting" + or session.phase == "enter" + or session.phase == "exit") +end + +-- Delay the engine's destructive finish until the camera has reached the +-- walking view. Repeated calls while the move is running are consumed. +function OverworldBattle.requestFinish(battle, callback) + if not (session and session.battle == battle and not session.broken) then + return false + end + if session.phase == "exit" then return true end + if not session.camera then return false end + session.phase = "exit" + session.transitionT = 0 + session.exitFrom = session.camera + session.finishCallback = callback + return true +end + -- ------- per-frame -- -- Driven from the voxel pipeline's update hook, which the engine ticks every @@ -253,10 +279,33 @@ function OverworldBattle.update(dt) return end - BattleCam.update(dt) -- the battle only exists once it has been pushed; a session opened at -- pushBattle time has it, one opened from battle.started was handed it session.battle = session.battle or (top ~= ow and top or nil) + + if top == session.battle then + if session.phase == "waiting" then + session.phase = "enter" + session.transitionT = 0 + elseif session.phase == "enter" or session.phase == "exit" then + session.transitionT = math.min( + 1, session.transitionT + (tonumber(dt) or 0) / BattleSweep.DURATION) + elseif session.phase == "battle" then + BattleCam.update(dt) + end + end + + local battleCamera = BattleScene.battleCamera(session.state, session.arena) + if session.phase == "enter" then + session.camera = BattleSweep.camera( + session.freeCamera or battleCamera, battleCamera, session.transitionT) + elseif session.phase == "exit" then + session.camera = BattleSweep.camera( + session.exitFrom or battleCamera, + session.freeCamera or battleCamera, session.transitionT) + else + session.camera = battleCamera + end -- the world pass is hidden behind the battle, so mesh builds get the wide -- slice: nothing visible can hitch on them ChunkMesher.pump(true) @@ -264,11 +313,16 @@ function OverworldBattle.update(dt) -- The mons' textures are rendered HERE, with no canvas bound, for the same -- reason the scene is: the pics layer binds its own targets, and doing that -- inside somebody else's frame means putting the frame back afterwards. - local okTex, textures = pcall(OverworldBattle.textures, session.battle) + local textures = nil + local wantTextures = session.phase == "battle" or session.phase == "exit" + local okTex = true + if wantTextures then + okTex, textures = pcall(OverworldBattle.textures, session.battle) + end if not okTex then textures = nil end session.token = (session.token or 0) + 1 local ok, shot = pcall(BattleScene.render, session.state, session.arena, - textures, session.token) + textures, session.token, session.camera) if not ok then -- One failure retires the arena for THIS battle and nothing else: the -- battle screen carries on as the engine's own, the free-roam pipeline @@ -279,9 +333,18 @@ function OverworldBattle.update(dt) session.broken = true V.mod.log:warn("overworld battle scene failed: %s -- this battle draws " .. "on the plain battle background", tostring(shot)) + -- A rendering fallback must never become a gameplay lock. Entry simply + -- resumes as a plain battle; exit performs the engine teardown now. + if session.phase == "exit" then + local callback = session.finishCallback + session.finishCallback = nil + if callback then callback() end + elseif session.phase == "enter" then + session.phase = "battle" + end return end - if shot and shot.canvas then + if shot and shot.canvas and session.phase == "battle" then -- the depth of field is measured off the two marks: the slab in focus is -- the one the mons are standing in, at whatever the drift has done to -- where that lands @@ -297,6 +360,14 @@ function OverworldBattle.update(dt) pcall(BattleHud.build, shot.canvas) end session.shot = shot + + if session.phase == "enter" and session.transitionT >= 1 then + session.phase = "battle" + elseif session.phase == "exit" and session.transitionT >= 1 then + local callback = session.finishCallback + session.finishCallback = nil + if callback then callback() end + end end -- The finished shot for this frame, or nil when there is none and the battle @@ -605,10 +676,31 @@ function OverworldBattle.install() -- the white letterbox exists so the window matches the white battle -- canvas; there is a world out to the window edges now self.letterboxWhite = false + -- During the move the world is the whole shot. The battle furniture and + -- flat UI arrive only after the entry camera has landed, and disappear + -- before the return camera leaves, eliminating the hard state cut. + if OverworldBattle.transitioning(self) then return end OverworldBattle.drawHudPanels(self) withoutBackgroundFill(self, innerDraw) end + local innerUpdate = BattleState.update + function BattleState:update(dt) + if OverworldBattle.transitioning(self) then return end + return innerUpdate(self, dt) + end + + local innerFinish = BattleState.finish + function BattleState:finish() + -- Pay Day's first finish call only queues its payout message; it is not + -- an exit and must remain immediate. + if self.payDay and self.result == "win" then return innerFinish(self) end + if OverworldBattle.requestFinish(self, function() innerFinish(self) end) then + return + end + return innerFinish(self) + end + -- The mons are geometry standing on the map now, drawn in the 3D pass -- before this screen is composited at all, so the flat pics layer has -- nothing left to do here. Skipped rather than left to draw underneath, or diff --git a/main.lua b/main.lua index faf2595..4a605bc 100644 --- a/main.lua +++ b/main.lua @@ -620,7 +620,7 @@ mod.events:on("battle.ended", function() OverworldBattle.finish() end) -mod.exports.version = "1.1.1" +mod.exports.version = "1.1.3" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index f5a7633..c42824a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.1.1", + "version": "1.1.3", "api": 2, "entry": "main.lua", "profile": "content", @@ -15,5 +15,5 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, reached through a sweeping camera transition and shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." } diff --git a/mod.card b/mod.card index 762df5b..6fcd6a4 100644 --- a/mod.card +++ b/mod.card @@ -11,6 +11,7 @@ return { "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles", "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them", "with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field", + "the camera sweeps from the live overworld view into the battle rig and back out before control returns", "the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map", "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off", diff --git a/tests/battle_sweep_test.lua b/tests/battle_sweep_test.lua new file mode 100644 index 0000000..3c7db71 --- /dev/null +++ b/tests/battle_sweep_test.lua @@ -0,0 +1,42 @@ +local source = assert(loadfile("lib/BattleSweep.lua")) +local Sweep = source({}) + +assert(Sweep.DURATION == 0.7, "battle sweep duration") + +local function near(a, b, epsilon, message) + assert(math.abs(a - b) <= (epsilon or 1e-6), + ("%s: %.8f ~= %.8f"):format(message or "value", a, b)) +end + +local from = { + eye = { 0, 144, 0 }, + focus = { 0, 0, 0 }, + fov = math.rad(53), + curve = 0.001, +} +local to = { + eye = { 100, 40, 120 }, + focus = { 20, 4, 30 }, + fov = math.rad(18), + curve = 0, +} + +local start = Sweep.camera(from, to, 0) +local finish = Sweep.camera(from, to, 1) +for i = 1, 3 do + near(start.eye[i], from.eye[i], 1e-6, "start eye") + near(start.focus[i], from.focus[i], 1e-6, "start focus") + near(finish.eye[i], to.eye[i], 1e-6, "finish eye") + near(finish.focus[i], to.focus[i], 1e-6, "finish focus") +end +near(start.fov, from.fov, 1e-6, "start fov") +near(finish.fov, to.fov, 1e-6, "finish fov") +near(start.curve, from.curve, 1e-6, "start curve") +near(finish.curve, to.curve, 1e-6, "finish curve") + +local middle = Sweep.camera(from, to, 0.5) +assert(middle.eye[2] > (from.eye[2] + to.eye[2]) / 2, + "sweep lifts above the straight path") +assert(Sweep.ease(0) == 0 and Sweep.ease(1) == 1, "ease endpoints") + +print("battle sweep: all checks passed")