Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions lib/BattleScene.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions lib/BattleSweep.lua
Original file line number Diff line number Diff line change
@@ -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
104 changes: 98 additions & 6 deletions lib/OverworldBattle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -253,22 +279,50 @@ 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)

-- 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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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."
}
1 change: 1 addition & 0 deletions mod.card
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tests/battle_sweep_test.lua
Original file line number Diff line number Diff line change
@@ -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")