mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:10:49 +02:00
battle effect fixes
This commit is contained in:
@@ -210,6 +210,91 @@ end
|
||||
|
||||
BattleScene.monCards = monCards
|
||||
|
||||
-- The MOVE-ANIMATION layer's place in the world: a BILLBOARD facing the
|
||||
-- eye, for the GB-frame effects texture OverworldBattle.animTexture
|
||||
-- renders (the engine's own drawAnimLayer, caught on a canvas).
|
||||
--
|
||||
-- Effects are 2D drawings like the pics, and the pics' answer holds for
|
||||
-- them too: a drawing must FACE the eye that is looking (the mon cards
|
||||
-- yaw toward it per eye -- see monMatrix). So the frame stands on the
|
||||
-- arena's midpoint, yawed at the eye like the cards are, and the classic
|
||||
-- layout's two slot marks are pinned where each CELL lands on that plane
|
||||
-- along this very eye's own ray -- so from the eye that is looking, a
|
||||
-- burst authored at a slot sits exactly over the mon standing in for it,
|
||||
-- and a projectile crossing the frame crosses the arena. The vertical
|
||||
-- scale is the mon cards' own (FULL_W / FULL_PIC), so an effect is sized
|
||||
-- like the pics it plays over.
|
||||
--
|
||||
-- An eye standing (nearly) ON the arena's axis sees the two cells in
|
||||
-- line and the pinning degenerates; the frame then falls back to the
|
||||
-- fixed plane through both cells, which that eye views edge-on anyway.
|
||||
--
|
||||
-- Reads Voxel3D.eye at CALL time, like the cards -- call it per eye.
|
||||
-- Returns the model matrix for BattleBillboard's unit card (x -0.5..0.5,
|
||||
-- y 0..1 up, v flipped), or nil where the anchors are degenerate.
|
||||
function BattleScene.fxCard(arena, groundY, anchors)
|
||||
local p, e = anchors.player, anchors.enemy
|
||||
local dgb = e[1] - p[1]
|
||||
if math.abs(dgb) < 1 then return nil end
|
||||
local GW, GH = BattleScene.GB_W, BattleScene.GB_H
|
||||
local Px, Py, Pz = arena.player[1], groundY, arena.player[2]
|
||||
local Ex, Ey, Ez = arena.enemy[1], groundY, arena.enemy[2]
|
||||
local s = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
|
||||
local Mx, My, Mz = (Px + Ex) / 2, groundY, (Pz + Ez) / 2
|
||||
|
||||
local eye = Voxel3D.eye
|
||||
local yaw = BattleBillboard.yawToward(Mx, Mz, eye)
|
||||
local nx, nz = math.sin(yaw), math.cos(yaw) -- out of the frame, at the eye
|
||||
local rx, rz = math.cos(yaw), -math.sin(yaw) -- the frame's own right
|
||||
|
||||
-- where a world point sits ON the billboard, as (right, up) coordinates
|
||||
-- about the midpoint: slid along the eye's ray onto the plane, so the
|
||||
-- mark and the mon line up from exactly the seat that is looking
|
||||
local function inPlane(qx_, qy_, qz_)
|
||||
if eye then
|
||||
local dqx, dqy, dqz = qx_ - eye[1], qy_ - eye[2], qz_ - eye[3]
|
||||
local denom = dqx * nx + dqz * nz
|
||||
if math.abs(denom) > 1e-6 then
|
||||
local t = ((Mx - eye[1]) * nx + (Mz - eye[3]) * nz) / denom
|
||||
qx_ = eye[1] + dqx * t
|
||||
qy_ = eye[2] + dqy * t
|
||||
qz_ = eye[3] + dqz * t
|
||||
end
|
||||
end
|
||||
return (qx_ - Mx) * rx + (qz_ - Mz) * rz, qy_ - My
|
||||
end
|
||||
local pax, pay = inPlane(Px, Py, Pz)
|
||||
local eax, eay = inPlane(Ex, Ey, Ez)
|
||||
|
||||
if math.abs(eax - pax) < 4 then
|
||||
-- edge-on: the fixed plane through both cells, world-axis mapping
|
||||
local ux = (Ex - Px) / dgb
|
||||
local uy = (Ey - Py - s * (p[2] - e[2])) / dgb
|
||||
local uz = (Ez - Pz) / dgb
|
||||
local cx = Px + ux * (0.5 * GW - p[1])
|
||||
local cy = Py + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
|
||||
local cz = Pz + uz * (0.5 * GW - p[1])
|
||||
local nl = math.sqrt(ux * ux + uz * uz)
|
||||
local fx, fz = 0, 1
|
||||
if nl > 1e-9 then fx, fz = uz / nl, -ux / nl end
|
||||
return { ux * GW, 0, fx, cx,
|
||||
uy * GW, s * GH, 0, cy,
|
||||
uz * GW, 0, fz, cz,
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
-- in-plane travel per GB pixel of frame x, solved so both marks land:
|
||||
-- inPlane(gb) = (pax, pay) + U * (gbx - p.x) + (0, s) * (p.y - gby)
|
||||
local ux = (eax - pax) / dgb
|
||||
local uy = (eay - pay - s * (p[2] - e[2])) / dgb
|
||||
local cxp = pax + ux * (0.5 * GW - p[1])
|
||||
local cyp = pay + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
|
||||
return { rx * ux * GW, 0, nx, Mx + rx * cxp,
|
||||
uy * GW, s * GH, 0, My + cyp,
|
||||
rz * ux * GW, 0, nz, Mz + rz * cxp,
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
-- The sun has to see the mons too, or they stand on the ground without
|
||||
-- putting anything on it. They are the one thing in this scene that MOVES,
|
||||
-- so `token` -- a counter the caller bumps whenever a pic could have changed
|
||||
|
||||
+118
-2
@@ -68,7 +68,18 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
|
||||
OverworldBattle.LABEL,
|
||||
{ true, false }, { "ON", "OFF" })
|
||||
|
||||
-- Whether the VR row is ON -- read lazily, because VR requires modules
|
||||
-- that sit above this one. While it is, this mode stops being optional:
|
||||
-- the headset's battle seat, the pokedex screen and the effects plane
|
||||
-- all assume a fight standing on the world, and a white-field battle
|
||||
-- inside a headset is exactly the flat screen VR exists to replace.
|
||||
local function vrOn()
|
||||
local ok, vr = pcall(V.require, "VR")
|
||||
return ok and vr and vr.enabled and vr.enabled() or false
|
||||
end
|
||||
|
||||
function OverworldBattle.enabled()
|
||||
if vrOn() then return true end
|
||||
return OverworldBattle.setting:get() and true or false
|
||||
end
|
||||
|
||||
@@ -98,9 +109,12 @@ OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
||||
|
||||
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
|
||||
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
|
||||
-- battle screen already draws exactly this.
|
||||
-- battle screen already draws exactly this. And held OFF under VR: the
|
||||
-- headset stands both mons on the world -- a flat back pic pinned to the
|
||||
-- 2D frame would keep your own mon off the arena the battle seat looks at.
|
||||
function OverworldBattle.backPinned()
|
||||
if not OverworldBattle.enabled() then return false end
|
||||
if vrOn() then return false end
|
||||
return OverworldBattle.backSetting:get() and true or false
|
||||
end
|
||||
|
||||
@@ -478,6 +492,18 @@ function OverworldBattle.update(dt)
|
||||
-- because rendering them binds canvases, which the eye pass -- mid-scene
|
||||
-- when it wants them -- must never do; reading a stashed canvas is free.
|
||||
session.textures = textures
|
||||
-- and the move-animation layer, for the same eyes -- rendered only
|
||||
-- while a headset is actually watching, because only the VR world
|
||||
-- pass draws it (the flat screen has the animations in-frame already)
|
||||
session.animTex = nil
|
||||
local okVR, vrOn = pcall(function()
|
||||
local vr = V.require("VR")
|
||||
return vr.active and vr.active() or false
|
||||
end)
|
||||
if okVR and vrOn and session.battle then
|
||||
local okA, anim = pcall(OverworldBattle.animTexture, session.battle)
|
||||
if okA then session.animTex = anim end
|
||||
end
|
||||
session.token = (session.token or 0) + 1
|
||||
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
||||
textures, session.token)
|
||||
@@ -553,6 +579,87 @@ function OverworldBattle.worldCards()
|
||||
return BattleScene.monCards(session.arena, groundY, tex), tex, session.token
|
||||
end
|
||||
|
||||
-- The live session's BATTLE STATE, once the pushed battle has been met
|
||||
-- (session.battle fills in from the stack in update). The VR quad reads
|
||||
-- it to tell "the battle screen is on top" from "a menu is over the
|
||||
-- battle" -- the UI-only panel is right for the first and wrong for the
|
||||
-- second. nil with no session, a broken one, or a battle not yet pushed.
|
||||
function OverworldBattle.battle()
|
||||
if not (session and not session.broken) then return nil end
|
||||
return session.battle
|
||||
end
|
||||
|
||||
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
|
||||
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
|
||||
-- transparent canvas of its own. This is what stands the effects up in
|
||||
-- the VR eyes' world -- see worldAnim below -- the same move the pics
|
||||
-- made through sideTexture: let the engine draw what it always draws,
|
||||
-- catch it on a canvas, stand the canvas in the scene.
|
||||
local animLayer = nil
|
||||
-- the engine's own drawAnimLayer, captured by install(). Declared HERE,
|
||||
-- above the function that reads it: a local declared further down the
|
||||
-- chunk would leave this function reading a global of the same name --
|
||||
-- nil forever, and the effects silently absent from the eyes (the bug
|
||||
-- this comment is the tombstone of).
|
||||
local innerAnim = nil
|
||||
|
||||
function OverworldBattle.animTexture(battle)
|
||||
if not (innerAnim and battle) then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
if not animLayer then
|
||||
local ok, c = pcall(love.graphics.newCanvas,
|
||||
BattleScene.GB_W, BattleScene.GB_H)
|
||||
if not (ok and c) then return nil end
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
animLayer = c
|
||||
end
|
||||
local g = love.graphics
|
||||
local prevCanvas = g.getCanvas()
|
||||
local ok = pcall(function()
|
||||
g.push("all")
|
||||
g.origin()
|
||||
g.setCanvas(animLayer)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
innerAnim(battle, false)
|
||||
g.pop()
|
||||
end)
|
||||
if not ok then pcall(g.pop, g) end
|
||||
if prevCanvas then pcall(g.setCanvas, g, prevCanvas)
|
||||
else pcall(g.setCanvas, g) end
|
||||
return ok and animLayer or nil
|
||||
end
|
||||
|
||||
-- The staged fight's effects, for the VR eyes: the animation layer plus
|
||||
-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit
|
||||
-- authored at a slot lands on the mon standing in for that slot). nil
|
||||
-- while nothing is staged or no layer was rendered this frame.
|
||||
function OverworldBattle.worldAnim()
|
||||
if not (session and session.arena and not session.broken) then return nil end
|
||||
local tex = session.animTex
|
||||
if not tex then return nil end
|
||||
local host = (session.state and session.state.map) or nil
|
||||
if not host then return nil end
|
||||
local groundY = BattleScene.groundY(host, session.arena)
|
||||
local model = BattleScene.fxCard(session.arena, groundY,
|
||||
OverworldBattle.ANCHOR)
|
||||
if not model then return nil end
|
||||
return tex, model
|
||||
end
|
||||
|
||||
-- Where the staged fight STANDS -- the arena and its floor height -- for a
|
||||
-- camera that wants to look at it rather than draw it (the VR battle
|
||||
-- mount). Answered as soon as the stage exists, textures or not: the
|
||||
-- camera should be seated behind the fade before the first pic lands.
|
||||
-- nil whenever no fight is staged on the world.
|
||||
function OverworldBattle.stage()
|
||||
if not (session and session.arena and not session.broken) then return nil end
|
||||
local host = (session.state and session.state.map) or nil
|
||||
if not host then return nil end
|
||||
return session.arena, BattleScene.groundY(host, session.arena)
|
||||
end
|
||||
|
||||
function OverworldBattle.invalidate()
|
||||
BattleDOF.invalidate()
|
||||
BattleHud.invalidate()
|
||||
@@ -715,6 +822,8 @@ local texturing = nil
|
||||
local texCanvas = {}
|
||||
local innerPics = nil -- captured by install()
|
||||
local innerHUDs = nil -- likewise, for the snapped HUD layer
|
||||
-- (innerAnim, their sibling, is declared up beside animTexture, which
|
||||
-- sits earlier in the chunk than this group and must see the local)
|
||||
|
||||
local function texCanvasFor(side)
|
||||
local c = texCanvas[side]
|
||||
@@ -993,7 +1102,7 @@ function OverworldBattle.install()
|
||||
-- give them. They ride the average, which is where the pair's centre went
|
||||
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
|
||||
-- aimed at instead of drifting off it.
|
||||
local innerAnim = BattleState.drawAnimLayer
|
||||
innerAnim = BattleState.drawAnimLayer
|
||||
function BattleState:drawAnimLayer(colorized)
|
||||
local shot = self.dramaticShapeShot
|
||||
if not shot then return innerAnim(self, colorized) end
|
||||
@@ -1142,6 +1251,13 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
|
||||
return false
|
||||
end
|
||||
-- With a headset live the HUDs stay IN the GB frame -- the classic
|
||||
-- slots, on the glass drawHudPanels lays for the unsnapped path. Both
|
||||
-- of VR's battle screens (the floating panel and the pokedex's) crop
|
||||
-- to the letterbox, and a block snapped out to the window's edge would
|
||||
-- be cropped away with the window around it.
|
||||
local okV, vr = pcall(V.require, "VR")
|
||||
if okV and vr and vr.active and vr.active() then return false end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local rects, bandX = OverworldBattle.snapRects(shot)
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
|
||||
+79
-14
@@ -78,6 +78,19 @@ Sky.DITHER_START = 0.6
|
||||
-- ladder instead of changing character rung by rung.
|
||||
Sky.SPAN = 0.23
|
||||
|
||||
-- How much ELEVATION the gradient spans above the horizon, in radians, for
|
||||
-- a caller that anchors the sky IN SPACE rather than to the frame (the VR
|
||||
-- eyes -- see Voxel3D.beginScene). On the flat screen the bands run from
|
||||
-- the top edge of the frame down to the horizon, which is right for a
|
||||
-- camera whose pitch is the rung's: the frame IS the window on the sky.
|
||||
-- A headset's frame is wherever the head points, so glueing the zenith
|
||||
-- band to its top edge drags the whole gradient around with the head. An
|
||||
-- anchored caller instead hangs the gradient over a fixed slice of sky --
|
||||
-- horizon to ELEV_SPAN up -- and hands paint() the canvas row that span's
|
||||
-- top lands on this frame (the `top` argument), so tilting the head slides
|
||||
-- the frame across a sky that stays put.
|
||||
Sky.ELEV_SPAN = math.rad(55)
|
||||
|
||||
-- ------- the bands
|
||||
--
|
||||
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
|
||||
@@ -168,8 +181,15 @@ local SHADER_SRC = [[
|
||||
uniform Image ramp; // the bands, one texel each, top of the sky first
|
||||
uniform float count; // how many texels wide that ramp is
|
||||
uniform float edge; // the sky's bottom, in canvas pixels
|
||||
uniform float top; // where the deepest band begins, in canvas pixels --
|
||||
// 0 glues the gradient to the frame (the flat
|
||||
// screen); an anchored caller passes the row its
|
||||
// fixed elevation span starts on, often negative
|
||||
uniform float cell; // the diorama's pixel size, in canvas pixels
|
||||
uniform float start; // where the checker begins inside a band
|
||||
uniform float axisX; // the "toward the ground" direction on the canvas:
|
||||
uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips
|
||||
// it, and edge/top are distances along it
|
||||
uniform float alpha;
|
||||
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
|
||||
uniform vec2 glowPos; // the sun disc, in canvas pixels
|
||||
@@ -186,8 +206,10 @@ vec3 bandAt(float i) {
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float row = floor(sc.y / cell) * cell; // top of this cell row
|
||||
float pos = min(row / max(edge, 1.0), 1.0) * count;
|
||||
vec2 cc0 = floor(sc / cell) * cell; // top of this cell
|
||||
float row = cc0.x * axisX + cc0.y * axisY; // along the axis
|
||||
if (row > edge) { discard; } // below the horizon
|
||||
float pos = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0) * count;
|
||||
float base = min(floor(pos), count - 1.0);
|
||||
vec3 c = bandAt(base);
|
||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||
@@ -310,13 +332,14 @@ Sky._getShader = getShader -- named for the suite
|
||||
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
|
||||
-- quantised edges. For a driver that could not compile the shader -- which is
|
||||
-- also every headless run.
|
||||
local function paintFlat(w, h, bands, edge, alpha, cell)
|
||||
local function paintFlat(w, h, bands, edge, alpha, cell, top)
|
||||
local g = love.graphics
|
||||
local n = #bands
|
||||
local span = edge - (top or 0)
|
||||
local prev = 0
|
||||
for i = 1, n do
|
||||
local cut = (i == n) and math.min(h, math.ceil(edge))
|
||||
or math.floor(i / n * edge / cell + 0.5) * cell
|
||||
or math.floor(((top or 0) + i / n * span) / cell + 0.5) * cell
|
||||
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
|
||||
if cut > prev then
|
||||
local c = bands[i]
|
||||
@@ -383,7 +406,7 @@ function Sky.discRadius(h, cell, body)
|
||||
return r * cell, r
|
||||
end
|
||||
|
||||
local function paintDisc(body, edge, cell, w, h)
|
||||
local function paintDisc(body, edge, cell, w, h, axis)
|
||||
local g = love.graphics
|
||||
if not (body and body.y and g.setScissor) then return end
|
||||
local shades = Sky.discShades(body.moon)
|
||||
@@ -392,12 +415,31 @@ local function paintDisc(body, edge, cell, w, h)
|
||||
-- snap the centre to the cell grid, like everything else in this sky
|
||||
local bx = math.floor(body.x / cell) * cell + cell / 2
|
||||
local by = math.floor(body.y / cell) * cell + cell / 2
|
||||
if by - r * cell > edge then return end -- wholly below the horizon point
|
||||
-- wholly below the horizon point -- measured along the tilt axis when
|
||||
-- the caller has one (a rolled VR eye)
|
||||
local bt = axis and (bx * axis[1] + by * axis[2]) or by
|
||||
if bt - r * cell > edge then return end
|
||||
local core = shades[1]
|
||||
local main = shades[twilight and 3 or 2]
|
||||
local sx, sy, sw, sh = g.getScissor()
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
-- the scissor is axis-aligned and cannot follow a tilted horizon; under
|
||||
-- an axis the world drawn after this covers the ground side anyway, so
|
||||
-- the region opens to the frame and only the cull above clips the disc
|
||||
if axis then
|
||||
g.setScissor(0, 0, math.ceil(w), math.ceil(h))
|
||||
else
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
end
|
||||
local craterR = math.max(1, math.floor(r / 5))
|
||||
-- The disc's cells are drawn in the HORIZON'S frame, not the canvas's:
|
||||
-- under an axis (a VR eye that can roll) the whole pattern -- craters,
|
||||
-- rim dither, the cell grid itself -- is rotated to stay upright over
|
||||
-- the world, or a tipped head watches the moon's face spin in place.
|
||||
-- Level cameras rotate by zero and draw exactly what they always drew.
|
||||
local rot = axis and math.atan2(-axis[1], axis[2]) or 0
|
||||
g.push()
|
||||
g.translate(bx, by)
|
||||
if rot ~= 0 then g.rotate(rot) end
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
@@ -416,12 +458,13 @@ local function paintDisc(body, edge, cell, w, h)
|
||||
end
|
||||
if keep then
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", bx + dx * cell - cell / 2,
|
||||
by + dy * cell - cell / 2, cell, cell)
|
||||
g.rectangle("fill", dx * cell - cell / 2,
|
||||
dy * cell - cell / 2, cell, cell)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
g.pop()
|
||||
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -436,16 +479,29 @@ end
|
||||
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
|
||||
-- along; nil hangs nothing and warms nothing.
|
||||
--
|
||||
-- `top` anchors the gradient in space rather than to the frame: the canvas
|
||||
-- row band 1 starts on (often negative -- above the frame), from a caller
|
||||
-- that mapped a fixed elevation span to its own camera (see ELEV_SPAN).
|
||||
-- nil or 0 is the flat screen's behaviour: zenith band at the top edge.
|
||||
--
|
||||
-- `axis` tips the whole painting to a rolled camera's true horizon: a unit
|
||||
-- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine),
|
||||
-- with `horizonY` and `top` then read as distances ALONG it rather than as
|
||||
-- rows. nil is the level default. Only the shader path can tilt; the flat
|
||||
-- fallback paints level, which only a headless run ever sees.
|
||||
--
|
||||
-- Returns false when there is nothing to paint, in which case the caller's flat
|
||||
-- fill is the whole sky. That fill is the palest band, so a frame that declines
|
||||
-- this looks like a hazy day rather than like a bug.
|
||||
function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
function Sky.paint(w, h, sky, horizonY, cell, body, top, axis)
|
||||
local bands = sky and sky.bands
|
||||
if not (bands and bands[1]) then return false end
|
||||
if not (w and h and w > 0 and h > 0) then return false end
|
||||
local g = love.graphics
|
||||
if not (g and g.rectangle) then return false end
|
||||
local edge = Sky.region(h, horizonY)
|
||||
-- along an axis the caller's edge is already the signed distance and
|
||||
-- has no row to be clamped to; level callers keep the SPAN fallback
|
||||
local edge = axis and horizonY or Sky.region(h, horizonY)
|
||||
if not edge then return false end
|
||||
local alpha = sky[4] or 1
|
||||
cell = math.max(1, math.floor((cell or 1) + 0.5))
|
||||
@@ -474,6 +530,9 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
sh:send("ramp", ramp)
|
||||
sh:send("count", #bands)
|
||||
sh:send("edge", edge)
|
||||
sh:send("top", math.min(top or 0, edge - 1))
|
||||
sh:send("axisX", axis and axis[1] or 0)
|
||||
sh:send("axisY", axis and axis[2] or 1)
|
||||
sh:send("cell", cell)
|
||||
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
|
||||
sh:send("alpha", alpha)
|
||||
@@ -488,16 +547,22 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
if sent then
|
||||
g.setShader(sh)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
|
||||
-- tilted, the sky's reach is not a row: the full frame goes through
|
||||
-- the shader and the discard is the boundary
|
||||
local rectH = axis and h or math.min(h, math.ceil(edge))
|
||||
g.rectangle("fill", 0, 0, w, rectH)
|
||||
g.setShader()
|
||||
else
|
||||
sh = nil
|
||||
end
|
||||
end
|
||||
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
|
||||
if not sh then
|
||||
paintFlat(w, h, bands, axis and math.min(h, edge) or edge, alpha, cell,
|
||||
math.min(top or 0, edge - 1))
|
||||
end
|
||||
-- the disc goes over the glow, under nothing: plain rectangles, so it is
|
||||
-- there whether or not the shader built
|
||||
paintDisc(body, math.min(h, edge), cell, w, h)
|
||||
paintDisc(body, axis and edge or math.min(h, edge), cell, w, h, axis)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
|
||||
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
|
||||
|
||||
+263
-56
@@ -24,10 +24,16 @@
|
||||
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
|
||||
-- your head started -- lean in, walk around it; on 1ST you stand inside
|
||||
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
|
||||
-- walks where you look exactly as it does on the flat screen. The flat
|
||||
-- window keeps running as the mirror (left eye when the world is up), so
|
||||
-- menus stay usable at the desk and every existing input keeps working --
|
||||
-- v1 has no XR controller bindings on purpose.
|
||||
-- walks where you look exactly as it does on the flat screen. A STAGED
|
||||
-- FIGHT takes the camera from both: the headset snaps -- through a fade
|
||||
-- to black and back -- to the flat battle's own over-the-shoulder seat
|
||||
-- (VRRig.battleMount), and returns the same way when the fight ends;
|
||||
-- the 2D battle screen lights up on the POKEDEX in the tracked left
|
||||
-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all --
|
||||
-- the fight itself owns the view. The flat window keeps
|
||||
-- running as the mirror (left eye when the world is up), so menus stay
|
||||
-- usable at the desk and every existing input keeps working alongside
|
||||
-- the XR controllers.
|
||||
--
|
||||
-- Failure is a status, never a crash: no runtime, no headset, no GL
|
||||
-- interop, or a mid-session loss all land back on the flat screen with
|
||||
@@ -45,6 +51,7 @@ local BattleCam = V.require("BattleCam")
|
||||
local VRRig = V.require("VRRig")
|
||||
local VRXR = V.require("VRXR")
|
||||
local VRGL = V.require("VRGL")
|
||||
local Pokedex = V.require("Pokedex")
|
||||
|
||||
local VR = {}
|
||||
|
||||
@@ -52,7 +59,11 @@ local VR = {}
|
||||
-- spoken for, and a headset is not something to toggle by accident.
|
||||
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
|
||||
|
||||
-- where the diorama's UI panel floats vs first person's
|
||||
-- Where the diorama's UI panel floats vs first person's. These are the
|
||||
-- FALLBACK screens: wherever the pokedex is up and lit -- first
|
||||
-- person's menus, a battle's 2D scene -- no quad is submitted at all
|
||||
-- (see updateQuad), and these serve only the diorama and the no-tracked-
|
||||
-- controller case.
|
||||
local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 }
|
||||
local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 }
|
||||
|
||||
@@ -74,12 +85,58 @@ local lastOrbit = 4 -- the 50-degree rung, a sane middle
|
||||
local held = {} -- GB buttons this module is holding down
|
||||
local lastHandY = nil -- the gripping hand's height, last frame
|
||||
|
||||
-- First person's SNAP TURN: the right stick flicked left or right steps
|
||||
-- the whole XR-to-world mapping 45 degrees at a time (a smooth software
|
||||
-- turn is the classic comfort mistake -- vection with no vestibular
|
||||
-- signal; a snap is instant and the head does the rest). The offset
|
||||
-- turns the mapping itself, so the eyes, the walk direction and the
|
||||
-- pokedex all agree about which way the world now faces.
|
||||
local SNAP_TURN = math.rad(45)
|
||||
local fpYawOff = 0 -- accumulated snaps, radians
|
||||
local snapArmed = true -- re-arms when the stick returns to centre
|
||||
|
||||
local function wrapPi(a)
|
||||
return (a + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
|
||||
-- The battle snap, made a FADE rather than a cut: when a fight is staged
|
||||
-- on the world (or stops being), black rises over both eyes, the camera
|
||||
-- swaps mounts behind it, and black lifts. A teleport inside VR is the
|
||||
-- one camera move that should never be SEEN happening -- the world
|
||||
-- sliding to a new seat reads as the room moving.
|
||||
local FADE_TIME = 0.35 -- seconds each way: out, then back in
|
||||
local camMode = "explore" -- "explore" (diorama / 1ST) or "battle"
|
||||
local fadeAlpha = 0 -- the black over the eyes right now
|
||||
|
||||
-- The staged fight to look at, if there is one: arena, floor height.
|
||||
local function battleStage()
|
||||
local ok, arena, groundY = pcall(function()
|
||||
return V.require("OverworldBattle").stage()
|
||||
end)
|
||||
if not ok then return nil end
|
||||
return arena, groundY
|
||||
end
|
||||
|
||||
-- the palette closure the engine hands drawWorld; stashed there (see
|
||||
-- main.lua) because the VR frame renders from update, where no ctx exists
|
||||
VR.paletteFor = nil
|
||||
|
||||
-- Whether this platform can do VR AT ALL: the shipped loader and the GL
|
||||
-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies.
|
||||
-- Everywhere else -- Android above all -- the row is not offered on any
|
||||
-- menu, and a stored vr=true is ignored rather than read: a save that
|
||||
-- migrated over from the desktop must not leave a phone trying to start
|
||||
-- an OpenXR session (or silently forcing the battle rows). Headless runs
|
||||
-- have no love.system and answer true, which costs nothing: enabling VR
|
||||
-- there stops at VRXR.start like it always did.
|
||||
function VR.supported()
|
||||
local ok, os = pcall(function() return love.system.getOS() end)
|
||||
if not ok or not os then return true end
|
||||
return os == "Windows"
|
||||
end
|
||||
|
||||
function VR.enabled()
|
||||
return VR.setting:get() == true
|
||||
return VR.supported() and VR.setting:get() == true
|
||||
end
|
||||
|
||||
function VR.active()
|
||||
@@ -124,15 +181,68 @@ local function shutdown(reason)
|
||||
mirrorSrc = nil
|
||||
releaseInputs()
|
||||
BattleCam.still = false
|
||||
VoxelScene.spriteLean = nil
|
||||
Pokedex.clear()
|
||||
zoom, heightOff = 1, 0
|
||||
fpYawOff, snapArmed = 0, true
|
||||
camMode, fadeAlpha = "explore", 0
|
||||
status = reason or "off"
|
||||
end
|
||||
|
||||
VR.shutdown = shutdown -- named for the probe driver
|
||||
|
||||
-- Whether the flat screen is showing something the world pass cannot: a
|
||||
-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's
|
||||
-- screen both key on it.
|
||||
local function uiShowing()
|
||||
local ok, showing = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local top = Game.stack and Game.stack:top()
|
||||
return top ~= Game.overworld
|
||||
or (Game.overworld and Game.overworld.transitioning) or false
|
||||
end)
|
||||
return ok and showing or false
|
||||
end
|
||||
|
||||
-- ------- the pokedex's screen
|
||||
--
|
||||
-- What the device in the hand shows during a battle: the flat window --
|
||||
-- which IS the 2D battle screen for as long as the battle state draws --
|
||||
-- copied into a canvas the scene pass can texture with, cropped by UV to
|
||||
-- the battle's own letterbox so the screen wears the GB frame edge to
|
||||
-- edge. Menus over the battle (the party, the bag) ride along for free:
|
||||
-- they are the flat screen too, and reading them on the device in your
|
||||
-- hand is exactly the point.
|
||||
local dexCanvas = nil
|
||||
|
||||
local function dexScreen()
|
||||
local ok, out = pcall(function()
|
||||
local ww, wh = love.graphics.getPixelDimensions()
|
||||
if not (ww and ww > 0 and wh and wh > 0) then return nil end
|
||||
if not (dexCanvas and dexCanvas:getWidth() == ww
|
||||
and dexCanvas:getHeight() == wh) then
|
||||
dexCanvas = love.graphics.newCanvas(ww, wh)
|
||||
pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest")
|
||||
end
|
||||
local fbo = fboCache[dexCanvas]
|
||||
if not fbo then
|
||||
fbo = VRGL.canvasFBO(dexCanvas)
|
||||
fboCache[dexCanvas] = fbo
|
||||
end
|
||||
if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end
|
||||
local BattleScene = V.require("BattleScene")
|
||||
local lx, ly, s = BattleScene.letterbox()
|
||||
return { dexCanvas,
|
||||
lx / ww, ly / wh,
|
||||
(lx + BattleScene.GB_W * s) / ww,
|
||||
(ly + BattleScene.GB_H * s) / wh }
|
||||
end)
|
||||
return ok and out or nil
|
||||
end
|
||||
|
||||
-- ------- the world, once per eye
|
||||
|
||||
local function renderWorld(views)
|
||||
local function renderWorld(views, ctl)
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local ow = ok and Game.overworld or nil
|
||||
if not (ow and ow.map and ow.camera and Voxel.active()
|
||||
@@ -142,19 +252,39 @@ local function renderWorld(views)
|
||||
local vw, vh = 320, 288
|
||||
pcall(function() vw, vh = Game.renderer:worldViewSize() end)
|
||||
|
||||
local pivot, anchor, scale
|
||||
-- Whatever the camera does, the CARDS hold the top rung's near-upright
|
||||
-- lean: a head that roams has no one pitch for them to match, and 75
|
||||
-- degrees is the pose that reads as "standing" from anywhere. Cleared
|
||||
-- on shutdown, so the flat screen leans with the rung as ever.
|
||||
VoxelScene.spriteLean = math.rad(75)
|
||||
|
||||
local pivot, anchor, scale, mountYaw
|
||||
local fp = FirstPerson.engaged()
|
||||
if fp then
|
||||
local battle, battleFloor
|
||||
if camMode == "battle" then battle, battleFloor = battleStage() end
|
||||
if battle then
|
||||
-- the over-the-shoulder seat the flat battle shot stands in, pulled
|
||||
-- close enough for a headset's own lens (see VRRig.battleMount), at
|
||||
-- life scale, turned to face the arena
|
||||
local rec = BattleCam.rig(battle, battleFloor)
|
||||
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
|
||||
anchor = { 0, 0, 0 }
|
||||
scale = VRRig.FP_SCALE
|
||||
elseif fp then
|
||||
local p = ow.player
|
||||
local gh = 0
|
||||
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
|
||||
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
|
||||
anchor = { 0, 0, 0 }
|
||||
scale = VRRig.FP_SCALE
|
||||
-- the HMD is the head: its yaw and pitch become FirstPerson's, so
|
||||
-- FreeMove walks where you look and A talks to what you face
|
||||
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
|
||||
-- turns through
|
||||
if fpYawOff ~= 0 then mountYaw = fpYawOff end
|
||||
-- the HMD is the head: its yaw and pitch (plus the snaps) become
|
||||
-- FirstPerson's, so FreeMove walks where you look and A talks to
|
||||
-- what you face
|
||||
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
|
||||
FirstPerson.yaw = yaw
|
||||
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
|
||||
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
|
||||
math.min(FirstPerson.PITCH_DOWN, pitch))
|
||||
else
|
||||
@@ -168,14 +298,37 @@ local function renderWorld(views)
|
||||
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
|
||||
end
|
||||
|
||||
-- The pokedex, on the tracked left hand, under this very mapping --
|
||||
-- but only where it earns its keep: FIRST PERSON, where its screen is
|
||||
-- every menu, dialog and wipe the flat screen shows (and the floating
|
||||
-- billboard is retired outright -- see updateQuad), and the BATTLE
|
||||
-- seat, where its screen is the fight's own 2D scene. The diorama
|
||||
-- does without: a hand-sized device hovering over a tabletop town is
|
||||
-- clutter, and the panel serves there. No hand tracked, no device.
|
||||
local hand = ctl and ctl.handl or nil
|
||||
if hand and (battle or fp) then
|
||||
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
|
||||
if uiShowing() then
|
||||
local scr = dexScreen()
|
||||
if scr then
|
||||
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
|
||||
end
|
||||
end
|
||||
else
|
||||
Pokedex.clear()
|
||||
end
|
||||
|
||||
local eyes = {}
|
||||
for i = 1, 2 do
|
||||
local v = views[i]
|
||||
eyes[i] = {
|
||||
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale),
|
||||
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
|
||||
w = v.w, h = v.h,
|
||||
slot = i == 1 and "vrL" or "vrR",
|
||||
adopt = true,
|
||||
-- the battle seat is a placed shot, not the first-person rig: the
|
||||
-- cards keep their stage lean rather than yawing at this eye, and
|
||||
-- the player's own card stays visible in it
|
||||
adopt = not battle,
|
||||
}
|
||||
end
|
||||
eyes.cx, eyes.cy = pivot[1], pivot[3]
|
||||
@@ -187,6 +340,21 @@ local function renderWorld(views)
|
||||
return false
|
||||
end
|
||||
|
||||
-- the snap's fade, over the finished eyes: plain black at this moment's
|
||||
-- strength, drawn before the blit so the headset never sees the swap
|
||||
if fadeAlpha > 0 then
|
||||
pcall(function()
|
||||
for i = 1, 2 do
|
||||
local c = canvases[i]
|
||||
love.graphics.setCanvas(c)
|
||||
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
|
||||
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
end
|
||||
|
||||
for i = 1, 2 do
|
||||
local canvas = canvases[i]
|
||||
local tex, tw, th = VRXR.acquireEye(i)
|
||||
@@ -214,24 +382,44 @@ end
|
||||
-- world pass is off entirely.
|
||||
local function wantQuad(worldUp)
|
||||
if not worldUp then return true end
|
||||
local ok, showing = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local top = Game.stack and Game.stack:top()
|
||||
return top ~= Game.overworld
|
||||
or (Game.overworld and Game.overworld.transitioning) or false
|
||||
end)
|
||||
return ok and showing or false
|
||||
return uiShowing()
|
||||
end
|
||||
|
||||
local function updateQuad(worldUp, fp)
|
||||
if not wantQuad(worldUp) then return nil end
|
||||
-- Wherever the pokedex is up and lit -- first person's menus, the
|
||||
-- battle seat's 2D fight -- it IS the screen, and no floating
|
||||
-- billboard is submitted at all. (No tracked left hand still gets
|
||||
-- the panel: the UI must be readable somewhere.)
|
||||
if Pokedex.frame and Pokedex.frame.tex then return nil end
|
||||
local tex, qw, qh = VRXR.acquireQuad()
|
||||
if not tex then return nil end
|
||||
local ww, wh = qw, qh
|
||||
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
|
||||
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
|
||||
VRXR.releaseQuad()
|
||||
return fp and QUAD_FP or QUAD_DIORAMA
|
||||
-- The panel wears the GB FRAME, not the window: everything the flat
|
||||
-- screen has to say lives in the 160x144 letterbox (the world around
|
||||
-- it is just the mirror's picture), so the quad's sub-rect crops to
|
||||
-- it and the panel presents near-square instead of monitor-wide.
|
||||
-- Image space is GL's, origin bottom-left -- exactly how
|
||||
-- copyFrontBuffer landed the window in the texture.
|
||||
local crop = nil
|
||||
pcall(function()
|
||||
local BattleScene = V.require("BattleScene")
|
||||
local lx, ly, s = BattleScene.letterbox()
|
||||
local wpx = BattleScene.GB_W * s
|
||||
local hpx = BattleScene.GB_H * s
|
||||
local x = math.max(0, math.floor(lx))
|
||||
local y = math.max(0, math.floor(wh - ly - hpx))
|
||||
crop = { x, y,
|
||||
math.min(qw - x, math.ceil(wpx)),
|
||||
math.min(qh - y, math.ceil(hpx)) }
|
||||
if crop[3] < 1 or crop[4] < 1 then crop = nil end
|
||||
end)
|
||||
local base = fp and QUAD_FP or QUAD_DIORAMA
|
||||
if not crop then return base end
|
||||
return { pos = base.pos, width = base.width, crop = crop }
|
||||
end
|
||||
|
||||
-- ------- the controllers
|
||||
@@ -242,10 +430,13 @@ end
|
||||
-- so it grid-walks the diorama and free-walks 1ST);
|
||||
-- A/B are A/B; either trigger is START; clicking the
|
||||
-- LEFT stick toggles first/third person.
|
||||
-- diorama only right stick up/down zooms the model; clicking the
|
||||
-- RIGHT stick cycles the viewing angle through the orbit
|
||||
-- rungs; squeezing a grip and moving that hand up or
|
||||
-- down drags the whole table with it.
|
||||
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
|
||||
-- diorama only right stick up/down zooms the model; squeezing a grip
|
||||
-- and moving that hand up or down drags the whole table
|
||||
-- with it.
|
||||
--
|
||||
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
|
||||
-- no controller button does it. VR.leave below stays as the API for it.
|
||||
|
||||
-- Flip between the 1ST rung and the last orbit rung, through the same
|
||||
-- gate and plumbing the keyboard hotkey uses.
|
||||
@@ -269,33 +460,15 @@ function VR.toggleView()
|
||||
end)
|
||||
end
|
||||
|
||||
-- Step the diorama's presentation angle through the orbit rungs (the
|
||||
-- anchor re-derives from the rung's angle, so the table re-tilts on the
|
||||
-- rung's own tween). FULL counts as its 35-degree twin, like the hotkey.
|
||||
function VR.cycleAngle()
|
||||
-- Leave VR: the VR row toggled back off and persisted, exactly as if
|
||||
-- stepped on the OPTIONS menu, so the next update tears the session down
|
||||
-- and the flat screen takes the picture back. Deliberately bound to NO
|
||||
-- controller button (a click that ejects you from the headset is a trap
|
||||
-- mid-fight); kept as the one programmatic door out.
|
||||
function VR.leave()
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local top = Game.stack and Game.stack:top()
|
||||
if not Pipelines.canToggle("voxel", top, Game.overworld) then return end
|
||||
local level = Pipelines.level("voxel")
|
||||
if Voxel.isFirstPerson(level) then return end
|
||||
local order = { 2, 3, 4, 5 }
|
||||
local at = nil
|
||||
for i, rung in ipairs(order) do
|
||||
if rung == level then at = i break end
|
||||
end
|
||||
if not at then
|
||||
local deg = Voxel.ANGLES_DEG[level + 1]
|
||||
for i, rung in ipairs(order) do
|
||||
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
|
||||
end
|
||||
end
|
||||
Pipelines.setLevel("voxel", order[at and (at % #order + 1) or 1])
|
||||
if Game.save and Game.save.options then
|
||||
Pipelines.syncOptions(Game.save.options)
|
||||
pcall(Game.writeOptions, Game)
|
||||
end
|
||||
VR.setting:setIndex(VR.setting:read() + 1, Game)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -331,12 +504,28 @@ local function driveControls(ctl, dt, fp)
|
||||
|
||||
if ctl.toggleChanged and ctl.toggle then VR.toggleView() end
|
||||
|
||||
if not fp then
|
||||
-- first person's snap turn: a flick of the right stick steps the view
|
||||
-- 45 degrees, once per flick -- it re-arms only when the stick comes
|
||||
-- back toward centre, so holding it turns exactly once
|
||||
if fp and camMode ~= "battle" then
|
||||
local sx = ctl.lookX or 0
|
||||
if math.abs(sx) > 0.65 then
|
||||
if snapArmed then
|
||||
snapArmed = false
|
||||
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||
-- pushed right subtracts
|
||||
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
|
||||
end
|
||||
elseif math.abs(sx) < 0.35 then
|
||||
snapArmed = true
|
||||
end
|
||||
end
|
||||
|
||||
if not fp and camMode ~= "battle" then
|
||||
local zy = ctl.lookY or 0
|
||||
if math.abs(zy) > 0.15 then
|
||||
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
|
||||
end
|
||||
if ctl.angleChanged and ctl.angle then VR.cycleAngle() end
|
||||
-- the grab-drag: while a grip is squeezed, the table follows that
|
||||
-- hand's height, metre for metre
|
||||
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
|
||||
@@ -406,18 +595,33 @@ function VR.update(dt)
|
||||
-- VR reads as the world lurching
|
||||
BattleCam.still = true
|
||||
|
||||
-- The battle snap's fade: while the camera the frame WANTS is not the
|
||||
-- one it is showing, black rises; at full black the mount swaps; then
|
||||
-- black lifts. Driven here, on game time, so a fight that ends during
|
||||
-- the fade just turns it around.
|
||||
local want = battleStage() and "battle" or "explore"
|
||||
if want ~= camMode then
|
||||
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
|
||||
if fadeAlpha >= 1 then camMode = want end
|
||||
else
|
||||
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
|
||||
end
|
||||
|
||||
local time, should = VRXR.waitFrame()
|
||||
if not time then return end
|
||||
|
||||
-- the controllers, before the world renders: the frame the toggle
|
||||
-- flips rungs on should be the frame that renders the new rig
|
||||
driveControls(VRXR.input(time), dt, FirstPerson.engaged())
|
||||
-- flips rungs on should be the frame that renders the new rig. The
|
||||
-- state is kept in hand for renderWorld too -- the pokedex stands on
|
||||
-- the same frame's left-hand pose.
|
||||
local ctl = VRXR.input(time)
|
||||
driveControls(ctl, dt, FirstPerson.engaged())
|
||||
|
||||
local worldUp = false
|
||||
if should then
|
||||
local views = VRXR.locateViews(time)
|
||||
if views then
|
||||
worldUp = renderWorld(views)
|
||||
worldUp = renderWorld(views, ctl)
|
||||
end
|
||||
end
|
||||
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
|
||||
@@ -458,6 +662,9 @@ function VR.invalidate()
|
||||
pcall(mirrorCanvas.release, mirrorCanvas)
|
||||
end
|
||||
mirrorCanvas, mirrorSrc = nil, nil
|
||||
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
|
||||
dexCanvas = nil
|
||||
Pokedex.invalidate()
|
||||
for k in pairs(fboCache) do fboCache[k] = nil end
|
||||
end
|
||||
|
||||
|
||||
@@ -166,6 +166,32 @@ function VRGL.copyFrontBuffer(tex, w, h)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from
|
||||
-- canvasFBO), flipped so the canvas reads top-down exactly like the
|
||||
-- window: what LOVE then draws from that canvas at (0,0) is the screen,
|
||||
-- row for row. The battle's VR quad is the caller: it needs the screen as
|
||||
-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a
|
||||
-- finished texture -- copyFrontBuffer above is for the finished case.
|
||||
function VRGL.copyFrontToCanvas(dstFBO, w, h)
|
||||
if not ready then return false end
|
||||
local ok = pcall(function()
|
||||
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.FRONT)
|
||||
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO)
|
||||
ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0,
|
||||
GL.COLOR_BUFFER_BIT, GL.NEAREST)
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
if not ok then
|
||||
pcall(function()
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
function VRGL.status()
|
||||
if ready then return "ok" end
|
||||
return reason or "not loaded"
|
||||
|
||||
+67
-5
@@ -85,6 +85,38 @@ end
|
||||
-- an angle nobody supplied
|
||||
VRRig.TABLE = { 0, -0.45, -0.75 }
|
||||
|
||||
-- ------- the battle mount
|
||||
--
|
||||
-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same
|
||||
-- line the flat battle camera stands on (eye through focus, so the player's
|
||||
-- mon is near-left and the foe far-right exactly as the flat shot frames
|
||||
-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from
|
||||
-- fifteen metres back, and a headset's lens is its own eyes, so keeping the
|
||||
-- distance would shrink the fight to a stage seen from the back row. 66 px
|
||||
-- is the wide rig's own standing distance: six and a half metres at life
|
||||
-- scale, close enough to fill the view, far enough to hold both mons in it
|
||||
-- -- and short enough to stay inside the small rooms the wide rig exists
|
||||
-- for.
|
||||
VRRig.BATTLE_DIST = 66
|
||||
|
||||
-- Where the head sits for a staged fight, and which way the mapping must
|
||||
-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR
|
||||
-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera
|
||||
-- looks along focus - eye, the resting headset looks along XR -Z (world
|
||||
-- north), and the yaw is what closes that gap.
|
||||
function VRRig.battleMount(eye, focus)
|
||||
local dx = eye[1] - focus[1]
|
||||
local dy = eye[2] - focus[2]
|
||||
local dz = eye[3] - focus[3]
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end
|
||||
local k = VRRig.BATTLE_DIST / len
|
||||
-- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming
|
||||
-- that along the horizontal of focus - eye solves to atan2 of eye - focus
|
||||
return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k },
|
||||
math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- eye-space clip planes, in metres (see the unit note above)
|
||||
VRRig.NEAR = 0.05
|
||||
VRRig.FAR = 400
|
||||
@@ -98,19 +130,25 @@ VRRig.FAR = 400
|
||||
-- pivot {x,y,z} world px pinned to `anchor`
|
||||
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
|
||||
-- scale world px per metre
|
||||
-- yaw optional turn of the whole mapping about +Y, radians: the
|
||||
-- battle mount faces the resting head at the arena with it.
|
||||
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
|
||||
--
|
||||
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
|
||||
-- eye and focus (for setLook, the water's lean, the sky), fov as a
|
||||
-- vertical span, and the curve declined -- a bent tabletop reads as a
|
||||
-- broken model, and first person already declines it on the flat screen.
|
||||
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale)
|
||||
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
|
||||
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
|
||||
local q = pose.quat
|
||||
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
|
||||
|
||||
-- view = R^T * T(-pos) * T(anchor) * S(1/s) * T(-pivot)
|
||||
-- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot)
|
||||
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
|
||||
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
|
||||
if yaw and yaw ~= 0 then
|
||||
view = Mat4.mul(view, Mat4.rotateY(-yaw))
|
||||
end
|
||||
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
|
||||
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
|
||||
|
||||
@@ -120,11 +158,17 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale)
|
||||
|
||||
-- the eye and its forward, in world pixels: worldFromEye applied to the
|
||||
-- origin and to -Z
|
||||
local ex = pivot[1] + scale * (px - anchor[1])
|
||||
local ey = pivot[2] + scale * (py - anchor[2])
|
||||
local ez = pivot[3] + scale * (pz - anchor[3])
|
||||
local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3]
|
||||
-- R's third column is the eye's +Z axis; forward is its negation
|
||||
local fx, fy, fz = -R[3], -R[7], -R[11]
|
||||
if yaw and yaw ~= 0 then
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
ax, az = c * ax + s * az, -s * ax + c * az
|
||||
fx, fz = c * fx + s * fz, -s * fx + c * fz
|
||||
end
|
||||
local ex = pivot[1] + scale * ax
|
||||
local ey = pivot[2] + scale * ay
|
||||
local ez = pivot[3] + scale * az
|
||||
|
||||
return {
|
||||
view = view,
|
||||
@@ -136,6 +180,24 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale)
|
||||
}
|
||||
end
|
||||
|
||||
-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the
|
||||
-- same mapping the eyes use -- so the prop is exactly where the hand is,
|
||||
-- whatever mode the mapping is in) composed with the hand's own tracked
|
||||
-- pose. A mesh authored in METRES rides it straight: the mapping's scale
|
||||
-- is what turns metres into world pixels, so the prop keeps its real
|
||||
-- size in the hand at the diorama's scale and at life scale alike.
|
||||
--
|
||||
-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat)
|
||||
function VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||
local m = Mat4.translate(pivot[1], pivot[2], pivot[3])
|
||||
m = Mat4.mul(m, Mat4.scale(scale, scale, scale))
|
||||
if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end
|
||||
m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3]))
|
||||
m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3]))
|
||||
local q = pose.quat
|
||||
return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4]))
|
||||
end
|
||||
|
||||
-- The flat compass numbers a head orientation implies, for driving
|
||||
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
|
||||
-- convention (0 south, pi/2 east) and pitch positive-down.
|
||||
|
||||
+28
-9
@@ -477,8 +477,6 @@ local function setupInput()
|
||||
start = makeAction(set, "btn_start", XR.ACTION_TYPE_BOOLEAN, "Start"),
|
||||
toggle = makeAction(set, "viewtoggle", XR.ACTION_TYPE_BOOLEAN,
|
||||
"First / Third Person"),
|
||||
angle = makeAction(set, "angle", XR.ACTION_TYPE_BOOLEAN,
|
||||
"Diorama Angle"),
|
||||
gripl = makeAction(set, "grip_l", XR.ACTION_TYPE_FLOAT, "Left Grip"),
|
||||
gripr = makeAction(set, "grip_r", XR.ACTION_TYPE_FLOAT, "Right Grip"),
|
||||
handl = makeAction(set, "hand_l", XR.ACTION_TYPE_POSE, "Left Hand"),
|
||||
@@ -511,7 +509,6 @@ local function setupInput()
|
||||
{ A.start, "/user/hand/left/input/trigger/value" },
|
||||
{ A.start, "/user/hand/right/input/trigger/value" },
|
||||
{ A.toggle, "/user/hand/left/input/thumbstick/click" },
|
||||
{ A.angle, "/user/hand/right/input/thumbstick/click" },
|
||||
{ A.gripl, "/user/hand/left/input/squeeze/value" },
|
||||
{ A.gripr, "/user/hand/right/input/squeeze/value" },
|
||||
{ A.handl, "/user/hand/left/input/grip/pose" },
|
||||
@@ -534,7 +531,6 @@ local function setupInput()
|
||||
{ A.start, "/user/hand/left/input/trigger/value" },
|
||||
{ A.start, "/user/hand/right/input/trigger/value" },
|
||||
{ A.toggle, "/user/hand/left/input/thumbstick/click" },
|
||||
{ A.angle, "/user/hand/right/input/thumbstick/click" },
|
||||
{ A.gripl, "/user/hand/left/input/squeeze/click" },
|
||||
{ A.gripr, "/user/hand/right/input/squeeze/click" },
|
||||
{ A.handl, "/user/hand/left/input/grip/pose" },
|
||||
@@ -617,7 +613,6 @@ function VRXR.input(time)
|
||||
o.b, o.bChanged = readBool(A.b)
|
||||
o.start, o.startChanged = readBool(A.start)
|
||||
o.toggle, o.toggleChanged = readBool(A.toggle)
|
||||
o.angle, o.angleChanged = readBool(A.angle)
|
||||
o.gripL = readFloat(A.gripl)
|
||||
o.gripR = readFloat(A.gripr)
|
||||
|
||||
@@ -632,6 +627,13 @@ function VRXR.input(time)
|
||||
local fl = tonumber(loc.locationFlags) or 0
|
||||
if fl % 4 >= XR.SPACE_LOCATION_POSITION_VALID_BIT then
|
||||
o[name .. "Y"] = loc.pose.position.y
|
||||
-- and the whole pose when the orientation is valid too
|
||||
-- (bit 0x1): the hand-held pokedex stands on it
|
||||
if fl % 2 >= 1 then
|
||||
local p, q = loc.pose.position, loc.pose.orientation
|
||||
o[name] = { pos = { p.x, p.y, p.z },
|
||||
quat = { q.x, q.y, q.z, q.w } }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -912,7 +914,12 @@ end
|
||||
|
||||
-- Close the frame. `world` submits the projection layer from the LAST
|
||||
-- locateViews (both eyes assumed blitted); `quadOpts` floats the UI panel:
|
||||
-- { pos = {x,y,z}, quat = {x,y,z,w}, width = metres }. Either may be nil.
|
||||
-- { pos = {x,y,z}, quat = {x,y,z,w}, width = metres,
|
||||
-- crop = {x, y, w, h} or nil }. Either may be nil. `crop` shows only
|
||||
-- that sub-rect of the quad's texture -- GL image coordinates, origin
|
||||
-- bottom-left, exactly as the front-buffer copy landed the window -- and
|
||||
-- the panel's aspect follows the rect (the GB letterbox's near-square
|
||||
-- frame) instead of the whole texture's.
|
||||
function VRXR.endFrame(time, world, quadOpts)
|
||||
if not running then return end
|
||||
pcall(function()
|
||||
@@ -946,8 +953,16 @@ function VRXR.endFrame(time, world, quadOpts)
|
||||
quadLayer[0].space = space
|
||||
quadLayer[0].eyeVisibility = XR.EYE_VISIBILITY_BOTH
|
||||
quadLayer[0].subImage.swapchain = quad.swapchain
|
||||
quadLayer[0].subImage.imageRect.extent.width = quad.w
|
||||
quadLayer[0].subImage.imageRect.extent.height = quad.h
|
||||
local cr = quadOpts.crop
|
||||
if cr then
|
||||
quadLayer[0].subImage.imageRect.offset.x = cr[1]
|
||||
quadLayer[0].subImage.imageRect.offset.y = cr[2]
|
||||
quadLayer[0].subImage.imageRect.extent.width = cr[3]
|
||||
quadLayer[0].subImage.imageRect.extent.height = cr[4]
|
||||
else
|
||||
quadLayer[0].subImage.imageRect.extent.width = quad.w
|
||||
quadLayer[0].subImage.imageRect.extent.height = quad.h
|
||||
end
|
||||
local p, q = quadOpts.pos or { 0, 0, -1 }, quadOpts.quat or { 0, 0, 0, 1 }
|
||||
quadLayer[0].pose.position.x = p[1]
|
||||
quadLayer[0].pose.position.y = p[2]
|
||||
@@ -958,7 +973,11 @@ function VRXR.endFrame(time, world, quadOpts)
|
||||
quadLayer[0].pose.orientation.w = q[4]
|
||||
local w = quadOpts.width or 1.0
|
||||
quadLayer[0].size.width = w
|
||||
quadLayer[0].size.height = w * (quad.h / quad.w)
|
||||
if cr then
|
||||
quadLayer[0].size.height = w * (cr[4] / math.max(1, cr[3]))
|
||||
else
|
||||
quadLayer[0].size.height = w * (quad.h / quad.w)
|
||||
end
|
||||
layers[#layers + 1] = quadLayer
|
||||
end
|
||||
|
||||
|
||||
+98
-4
@@ -585,6 +585,56 @@ function Voxel3D.horizonY(h)
|
||||
return (y / w * 0.5 + 0.5) * h
|
||||
end
|
||||
|
||||
-- The horizon as a LINE rather than a row, for a camera that can ROLL --
|
||||
-- a VR eye. A head tipped sideways tips the true horizon across the
|
||||
-- canvas, and a sky painted in flat rows then visibly hinges with the
|
||||
-- head. So: project the flat forward direction (a point ON the vanishing
|
||||
-- line) and the same direction nudged a hair of world-up (a point just
|
||||
-- above it); the difference is the canvas direction "down toward the
|
||||
-- ground", perpendicular to the horizon however the head is tipped.
|
||||
--
|
||||
-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from
|
||||
-- sky toward ground, the horizon's signed distance along it -- a pixel at
|
||||
-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and,
|
||||
-- when `elev` (radians) is given, the distance the direction that far
|
||||
-- ABOVE the horizon projects to. `top` is what pins the gradient's far
|
||||
-- end to a real direction in the sky: extrapolating it linearly from a
|
||||
-- pixels-per-radian estimate left the bands sliding as a pitch moved the
|
||||
-- horizon through the frame, because a perspective's rows are tan-spaced,
|
||||
-- not angle-spaced. nil `top` (the elevated direction is outside this
|
||||
-- frustum's forward hemisphere) leaves the caller its estimate. nil
|
||||
-- everything with no horizon in front of this camera.
|
||||
function Voxel3D.horizonLine(w, h, elev)
|
||||
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
|
||||
if not (m and eye and focus and w and h and h > 0) then return nil end
|
||||
local dx = focus[1] - eye[1]
|
||||
local dz = focus[3] - eye[3]
|
||||
local len = math.sqrt(dx * dx + dz * dz)
|
||||
if len < 1e-6 then return nil end
|
||||
dx, dz = dx / len, dz / len
|
||||
local function proj(vx, vy, vz)
|
||||
local x = m[1] * vx + m[2] * vy + m[3] * vz
|
||||
local y = m[5] * vx + m[6] * vy + m[7] * vz
|
||||
local ww = m[13] * vx + m[14] * vy + m[15] * vz
|
||||
if ww <= 1e-6 then return nil end
|
||||
return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h
|
||||
end
|
||||
local qx, qy = proj(dx, 0, dz)
|
||||
if not qx then return nil end
|
||||
local rx, ry = proj(dx, 0.02, dz)
|
||||
if not rx then return nil end
|
||||
local ax, ay = qx - rx, qy - ry
|
||||
local al = math.sqrt(ax * ax + ay * ay)
|
||||
if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end
|
||||
local top = nil
|
||||
if elev then
|
||||
local ce, se = math.cos(elev), math.sin(elev)
|
||||
local tx, ty = proj(dx * ce, se, dz * ce)
|
||||
if tx then top = tx * ax + ty * ay end
|
||||
end
|
||||
return ax, ay, qx * ax + qy * ay, top
|
||||
end
|
||||
|
||||
-- ------- the hour's light
|
||||
--
|
||||
-- What the scene shader multiplies every surface by (see dayTint in the
|
||||
@@ -689,10 +739,38 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- screen. The sky's dither grid is cut to it, and so is the water's --
|
||||
-- one number, so the two break up on the same checkerboard.
|
||||
Voxel3D.cell = w / math.max(1, vw or w)
|
||||
-- A VR eye's sky is ANCHORED IN SPACE, where the flat screen's is glued
|
||||
-- to the frame. The differences all key off the raw-matrix camera only
|
||||
-- the VR eyes bring: the horizon must really be in frame for any band
|
||||
-- to paint (no Sky.SPAN fallback -- that slice pinned to the top of the
|
||||
-- view is exactly "the sky moves with the headset"); the gradient hangs
|
||||
-- over a fixed ELEVATION span above the horizon rather than filling up
|
||||
-- to the frame's edge; the whole painting runs along the horizon's OWN
|
||||
-- AXIS (horizonLine), so a rolled head sees the horizon hold level in
|
||||
-- the world instead of hinging with the ears; and the sun or moon was
|
||||
-- already honest -- skyBody projects the hour's direction through this
|
||||
-- very eye.
|
||||
local vrEye = Voxel3D.camera and Voxel3D.camera.view and Voxel3D.camera.proj
|
||||
and true or false
|
||||
local hy = Voxel3D.horizonY(h)
|
||||
local ax, ay, edgeT, topT
|
||||
if vrEye then
|
||||
ax, ay, edgeT, topT = Voxel3D.horizonLine(w, h, Sky.ELEV_SPAN)
|
||||
end
|
||||
-- any sky in frame at all? the corner most toward the sky must sit
|
||||
-- above the horizon's line
|
||||
local skyUp = false
|
||||
if sky and sky.bands then
|
||||
if not vrEye then
|
||||
skyUp = true
|
||||
elseif edgeT then
|
||||
local minT = math.min(0, w * ax, h * ay, w * ax + h * ay)
|
||||
skyUp = edgeT > minT + 1
|
||||
end
|
||||
end
|
||||
-- and where the sky's bottom edge lands, which is what the reflection
|
||||
-- reads its bands against (see Water). nil when nothing painted bands.
|
||||
Voxel3D.skyEdge = (sky and sky.bands)
|
||||
and Sky.region(h, Voxel3D.horizonY(h)) or nil
|
||||
Voxel3D.skyEdge = skyUp and Sky.region(h, hy) or nil
|
||||
if sky then
|
||||
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
|
||||
-- The sky goes down here, in the one window in this function where a
|
||||
@@ -705,8 +783,24 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- are the same size as the world's own and follow every resize and zoom.
|
||||
-- The banded sky also hangs the hour's sun or moon (skyBody projects it
|
||||
-- through this very camera); a flat sky has no bands and hangs nothing.
|
||||
Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell,
|
||||
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||
if not vrEye then
|
||||
Sky.paint(w, h, sky, hy, Voxel3D.cell,
|
||||
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||
elseif skyUp then
|
||||
-- the gradient's far end: the ELEV_SPAN direction's own projection
|
||||
-- when the frustum holds it (horizonLine's `top` -- exact, so a
|
||||
-- pitch slides the frame over bands that stay put), and the
|
||||
-- pixels-per-radian estimate when it does not
|
||||
local top = topT
|
||||
if not (top and top < edgeT - 1) then
|
||||
local radPerPx = math.max(1e-6, (Voxel3D.fovY or 1) / h)
|
||||
top = edgeT - Sky.ELEV_SPAN / radPerPx
|
||||
end
|
||||
Sky.paint(w, h, sky, edgeT, Voxel3D.cell,
|
||||
Voxel3D.skyBody(w, h), top, { ax, ay })
|
||||
end
|
||||
-- a VR eye with no horizon in frame paints nothing: everything in view
|
||||
-- is below the horizon, and the haze clear above already filled it
|
||||
else
|
||||
love.graphics.clear(0, 0, 0, 0, true, true)
|
||||
end
|
||||
|
||||
+47
-9
@@ -26,6 +26,7 @@ local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local Pokedex = V.require("Pokedex")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -262,21 +263,32 @@ end
|
||||
-- carries one pose into the other -- the lean eases out as the yaw eases in
|
||||
-- -- and cardBlend is zero for every camera that is not the first-person
|
||||
-- rig, the battle's placed shot included, so nothing else moves.
|
||||
-- The pitch the sprite cards lean back by -- normally the rung's own
|
||||
-- camera angle, overridable in radians. VR sets the override to the top
|
||||
-- rung's 75 degrees for every diorama and battle frame: a table watched
|
||||
-- from a freely moving head has no one camera pitch for the cards to
|
||||
-- match, and the near-upright top-rung lean is the pose that reads as
|
||||
-- "standing" from anywhere around it. nil (the default, and the flat
|
||||
-- screen always) leans with the rung as ever.
|
||||
VoxelScene.spriteLean = nil
|
||||
|
||||
local function leanAngle()
|
||||
return VoxelScene.spriteLean or V.require("VoxelState").angle
|
||||
end
|
||||
|
||||
local function billboardMatrix(px, py, y, mirror)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local b = FirstPerson.cardBlend()
|
||||
local m = Mat4.translate(px + 8, y, py + 8)
|
||||
if b > 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||
end
|
||||
m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
||||
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
||||
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
||||
end
|
||||
|
||||
local function billboardPull()
|
||||
local Voxel = V.require("VoxelState")
|
||||
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
||||
return VoxelScene.pull(math.max(leanAngle(), 0.05))
|
||||
end
|
||||
|
||||
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
|
||||
@@ -295,7 +307,6 @@ end
|
||||
-- his edge would swing him off his seat. The width rode in on the record
|
||||
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
||||
local function figureMatrix(f, offX, offZ)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local b = FirstPerson.cardBlend()
|
||||
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
|
||||
local m = Mat4.translate(wx, f.y, wz)
|
||||
@@ -305,7 +316,7 @@ local function figureMatrix(f, offX, offZ)
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
|
||||
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
|
||||
end
|
||||
return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
return Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
||||
end
|
||||
|
||||
-- What the sun sees: the same card UNLEANED and flattened, exactly as
|
||||
@@ -1054,6 +1065,17 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
BattleBillboard.PULL)
|
||||
end
|
||||
if battleTex.flash then Voxel3D.flatten(nil) end
|
||||
-- and the MOVE ANIMATIONS, standing on the same arena: the
|
||||
-- engine's own effects layer on the plane through both cells
|
||||
-- (BattleScene.fxCard), pulled a little harder than the mons so
|
||||
-- a burst plays over the card it is bursting on
|
||||
local okA, fxTex, fxModel = pcall(function()
|
||||
return V.require("OverworldBattle").worldAnim()
|
||||
end)
|
||||
if okA and fxTex and fxModel then
|
||||
Voxel3D.draw(BattleBillboard.mesh(), fxTex, fxModel,
|
||||
BattleBillboard.PULL + 6)
|
||||
end
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.glass(true)
|
||||
end
|
||||
@@ -1064,8 +1086,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
-- is preserved, so the row still overdraws feet -- the 3D version of
|
||||
-- the GB's grass-over-feet trick -- while grass keeps losing to the
|
||||
-- buildings it genuinely stands behind (far deeper than the pull).
|
||||
local Voxel = V.require("VoxelState")
|
||||
local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
||||
-- the same angle the cards leaned by (leanAngle honours VR's override),
|
||||
-- so the tuft rows keep exactly the characters' own depth handicap
|
||||
local lean = math.max(leanAngle(), 0.05)
|
||||
local pull = VoxelScene.pull(lean)
|
||||
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
@@ -1081,7 +1105,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
-- lands behind the card and the player obscures the patch they stand
|
||||
-- ON, while the nearest flower of the cell south (+20) stays in front
|
||||
-- and keeps overdrawing their feet.
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
|
||||
local fpull = math.max(0, pull - 8 * math.sin(lean))
|
||||
-- flowers are snugged casters too, so they read their own shadowing
|
||||
-- through the same snugged transform the sun stored them with
|
||||
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
|
||||
@@ -1092,6 +1116,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
|
||||
-- The VR pokedex in the player's left hand, last of all: a prop over
|
||||
-- the world drawn with real depth, so leaning it into a wall still
|
||||
-- occludes honestly. Its frame only exists while a session is live and
|
||||
-- the left hand is tracked (VR.lua sets it), so every flat frame skips
|
||||
-- this in one field read. No wireframe and no glass, like the cast:
|
||||
-- the device is a drawing riding the scene, not part of the terrain.
|
||||
if Pokedex.frame then
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.seams(false)
|
||||
Pokedex.draw()
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.glass(true)
|
||||
end
|
||||
|
||||
end -- drawScene
|
||||
|
||||
if not eyes then
|
||||
|
||||
Reference in New Issue
Block a user