fix battle rotations in vr
DramaticShape · 2026-08-06 ·
parent 6080531b08
fix battle rotations in vr
Changed files
data/battle_arenas.lua | 138 +++++++++++++++++++++++++++++++++-----------------------------
lib/Diorama.lua | 35 ++++++++++++++++
lib/VR.lua | 6 ++-
tests/dramatic_shape_test.lua | 62 ++++++++++++++++++++++++++++
4 files changed, 176 insertions(+), 65 deletions(-)
Diff
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index 680cece..33f7164 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -63,14 +63,14 @@ return {
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
- ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
+ ["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
- ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
- ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
- ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
- ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
- ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
- ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
+ ["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
+ ["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
+ ["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
+ ["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
+ ["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
+ ["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
-- the whole route admits six bare wide arenas, all in the west cliff
-- corridor; this is the best of them. A flower cluster crosses the far
-- mon's hind legs, which the brief allows -- every alternative put a
@@ -78,13 +78,13 @@ return {
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
- ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
- ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
- ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
- ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
- ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
+ ["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
+ ["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
+ ["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
+ ["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
+ ["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
- ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
+ ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
-- ------- buildings and caves
--
@@ -92,10 +92,10 @@ return {
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
-- also SEEN, and giving up the apron is usually the difference between a
-- fight in the open and one behind a console.
- ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
- ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
- ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
- ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
+ ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
+ ["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
+ ["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
+ ["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
-- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -103,73 +103,79 @@ return {
-- is shot there
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
shape = "wide" },
- ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
- ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
+ ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
+ ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
- ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
- ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
- ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
- ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
+ ["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
+ ["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
+ ["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
- ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
- ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
- ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
+ ["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
+ ["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
- ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
- ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
- ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
- ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
+ ["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
+ ["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
+ ["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
-- Silph Co is office floors partitioned into small rooms, so the long lens
-- often lands outside the walls it is meant to be looking between; the
-- floors that could not be framed any other way ask for the wide one.
- ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
- ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
+ ["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
+ ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
- ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
+ ["SILPH_CO_6F"] = { x = 19, y = 2,
+ shape = "narrow", turn = 90, cam = "wide" },
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
- ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
+ ["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
-- aboard and the long lens always lands outside the hull
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
-- these two decks are byte-identical geometry, so they take the same spot
- ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
- ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+ ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
+ ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
- ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
- ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
+ ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
+ ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
-- ------- towns and the last interiors
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
- ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
+ ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
-- the lab is ten cells by twelve, so the long lens is always off-map, and
-- on it a desk clipped one mon and a pillar the other
- ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
+ ["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
-- Saffron's gym is a grid of small walled cells: no wide shape exists
-- anywhere in it, and the long lens sits inside a divider
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
- ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
- ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+ ["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
+ ["SILPH_CO_11F"] = { x = 10, y = 6,
+ shape = "wide", turn = 180, cam = "wide" },
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
-- gym's barrier, so arenas there silently fail the fit test
- ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
- ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
+ ["VERMILION_GYM"] = { x = 3, y = 2,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
-- ------- the remaining routes
- ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
+ ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
-- than on the rim of beach the land search would otherwise find
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
- ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
- ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
- ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
+ ["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
+ ["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
+ ["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
-- ------- caves, gyms and the Elite Four
@@ -177,25 +183,29 @@ return {
-- None of these tilesets has a grass tile at all, so the no-grass rule
-- constrained nothing here; what constrains them is furniture, rock
-- pillars and how small the rooms are.
- ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
- ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
- ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
- ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
+ ["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
+ ["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
+ ["CELADON_GYM"] = { x = 3, y = 4,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
-- hide a mon, so it borrows the floor below -- the same cave
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
shape = "wide" },
- ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
- ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
+ ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
+ ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
- ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
- ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
- ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
- ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
- ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
- ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
- ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
- ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
+ ["CINNABAR_GYM"] = { x = 9, y = 16,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["DIGLETTS_CAVE"] = { x = 14, y = 26,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["FIGHTING_DOJO"] = { x = 3, y = 5,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
+ ["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
+ ["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
+ ["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
+ ["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
-- The three gyms the default rig cannot stand back from. Five blocks is
-- further than these rooms are wide, so the eye landed outside the map and
@@ -204,7 +214,7 @@ return {
-- BattleCam), which fits inside the room; the mons come out smaller and all
-- three became stageable. It is asked for HERE, per map, so every area that
-- does not ask keeps the long lens it was framed for.
- ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
- ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
+ ["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
+ ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
}
diff --git a/lib/Diorama.lua b/lib/Diorama.lua
index c63b3b1..7df22b4 100644
--- a/lib/Diorama.lua
+++ b/lib/Diorama.lua
@@ -113,6 +113,41 @@ function Diorama.reset()
Diorama.release()
end
+-- ------- which way a staged fight lies on the table
+--
+-- The disc's bearing while a battle is up: the player's own hand-turn, with
+-- the ARENA's quarter turn taken back out of it.
+--
+-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
+-- the promise that field makes everywhere else is that turning it changes the
+-- GROUND under the fight and never the fight itself -- the two Pokemon land
+-- on the same marks, seen the same way round. Every other camera keeps that
+-- promise by construction: the flat shot and the standard VR mount are both
+-- built from BattleCam's eye, which turns with the arena, so the composition
+-- follows it round.
+--
+-- This one is not built from that eye. It is a disc of map lifted onto the
+-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
+-- staged on a turned arena arrived on the table lying across the head that
+-- was looking at it, while the same fight on an unturned one faced properly.
+-- Same fight, same composition everywhere else, sideways here.
+--
+-- So the turn comes back out. Subtracted, matching the sign the standard
+-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
+-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
+-- seats.
+--
+-- The hand-turn stays on top of it, because that is the player moving the
+-- model and is theirs to keep.
+function Diorama.battleYaw(arena)
+ local turn = (arena and arena.turn) or 0
+ if turn == 0 then return Diorama.yaw end
+ local yaw = Diorama.yaw - math.rad(turn)
+ -- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
+ -- care which way round it came
+ return (yaw + math.pi) % (2 * math.pi) - math.pi
+end
+
-- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is
-- not a diorama mode closes it.
function Diorama.begin(mode)
diff --git a/lib/VR.lua b/lib/VR.lua
index 3682f20..c95ccea 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -371,7 +371,11 @@ local function renderWorld(views, ctl)
end
anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom
- if Diorama.yaw ~= 0 then mountYaw = Diorama.yaw end
+ -- the hand-turn, and -- while a fight is staged -- the arena's own
+ -- quarter turn taken back out, so a turned arena arrives on the table
+ -- facing the head rather than lying across it (Diorama.battleYaw)
+ local dioYaw = battle and Diorama.battleYaw(battle) or Diorama.yaw
+ if dioYaw ~= 0 then mountYaw = dioYaw end
elseif 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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 977333f..4a7ecfa 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -5027,6 +5027,68 @@ local eyaw = select(2, VRRig.battleMount({ 150, 10, 200 }, { 100, 10, 200 }))
T.check(near(eyaw, math.pi / 2),
"a camera east of its focus turns the mapping a quarter toward west")
+-- ------- both VR seats follow the arena's quarter turn
+--
+-- `turn` promises the same composition on different ground, and every camera
+-- has to keep it or the promise is only true on the flat screen. The two VR
+-- seats keep it in different ways and both are pinned here.
+--
+-- THE STANDARD MOUNT keeps it by construction: it is built from the flat
+-- battle camera's own eye and aim, which turn with the arena, so the seat
+-- goes round with them. That is asserted rather than assumed, because the
+-- day someone builds this seat from the arena's cells instead, the picture
+-- stays plausible and starts pointing the wrong way.
+--
+-- THE DIORAMA disc does NOT get it for free: a fight arrives there as a
+-- pillar of map about the arena's midpoint, whose bearing is its bearing in
+-- the WORLD, so a turned arena landed on the table lying across the head.
+-- Diorama.battleYaw takes the turn back out; the hand-turn rides on top.
+--
+-- In its own scope: the suite's main chunk is at LuaJIT's local ceiling.
+;(function()
+ local Arena = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArena")
+ local Cam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
+ local Dio = run.loader.exports.DRAMATIC_SHAPE.lib.require("Diorama")
+
+ local function mountYawFor(turn)
+ Cam.reset()
+ local a = Arena.at(6, 6, "wide", turn)
+ local rig = Cam.rig(a, 0, true)
+ return select(2, VRRig.battleMount(rig.eye, rig.focus))
+ end
+
+ local base = mountYawFor(0)
+ for _, turn in ipairs({ 90, 180, 270 }) do
+ -- rotating (eye - focus) by +turn takes atan2(dx, dz) to (bearing - turn)
+ local want = (base - math.rad(turn) + math.pi) % (2 * math.pi) - math.pi
+ local got = (mountYawFor(turn) + math.pi) % (2 * math.pi) - math.pi
+ T.check(near(got, want, 1e-6),
+ ("the standard VR mount turns with a %d-degree arena: %.4f, wanted %.4f")
+ :format(turn, got, want))
+ end
+
+ -- the disc: the turn comes back out, so the fight lies the same way on the
+ -- table however the ground under it is laid
+ local hand = Dio.yaw
+ Dio.yaw = 0
+ T.eq(Dio.battleYaw({ turn = 0 }), 0,
+ "an unturned arena leaves the disc where the hands left it")
+ T.check(near(Dio.battleYaw({ turn = 90 }), -math.pi / 2, 1e-9),
+ "a quarter-turned arena counter-turns the disc a quarter")
+ T.check(near(Dio.battleYaw({ turn = 270 }), math.pi / 2, 1e-9),
+ "and three quarters comes round the other way, inside (-pi, pi]")
+ T.eq(Dio.battleYaw(nil), 0, "no arena is no turn, not an error")
+ -- and the same rule the standard mount lands on, so the two agree
+ T.check(near(Dio.battleYaw({ turn = 90 }) - Dio.battleYaw({ turn = 0 }),
+ mountYawFor(90) - mountYawFor(0), 1e-6),
+ "the disc and the standard mount answer a turn by the same amount")
+ -- the player's own hand-turn still rides on top of it
+ Dio.yaw = math.rad(30)
+ T.check(near(Dio.battleYaw({ turn = 90 }), math.rad(30) - math.pi / 2, 1e-9),
+ "the hands keep whatever they did to the model, on top of the turn")
+ Dio.yaw = hand
+end)()
+
-- an eye seated with that yaw really faces the arena: an identity head at
-- the seat comes out looking WEST, and the view agrees to the metre
local seated = VRRig.eyeCamera(pose, fov, { 150, 10, 200 }, { 0, 0, 0 },