From 53fff766a44e2ea1ad81717e4cc1e9bcbc7bad06 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Thu, 6 Aug 2026 19:56:39 -0400 Subject: [PATCH] revert battle locations temporarily --- CHANGELOG.md | 46 +++++++ README.md | 51 ++++++-- data/battle_arenas.lua | 138 ++++++++++---------- lib/ForestAtmos.lua | 56 +++++++- lib/VR.lua | 233 +++++++++++++++++++++++++++++++--- lib/VRRig.lua | 46 +++++-- lib/Voxel3D.lua | 160 ++++++++++++++++++++--- lib/VoxelScene.lua | 24 +++- lib/Water.lua | 36 +++++- lib/WorldCurve.lua | 23 +++- main.lua | 47 ++++++- manifest.json | 2 +- mod.card | 5 +- tests/dramatic_shape_test.lua | 216 +++++++++++++++++++++++++++++-- 14 files changed, 922 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f3a69..b1c1462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.7.0 + +### Added + +- **The DIORAMA modes: Kanto as a model you can pick up.** The VR row is a + ladder now -- OFF / STANDARD / DIORAMA / DIORAMA-MR. STANDARD is what the + mod already did (the headset follows the VOXEL ladder, orbit rungs a + tabletop and 1ST life size). DIORAMA is one presentation instead of a + ladder: the world is always the model on the table, and the model is a + thing in the room. + + Everything outside an invisible **box** centred on the view is simply + not drawn -- the Final Fantasy Tactics read, a square slab of the world + sitting in the air rather than a map running off to a horizon, cut with + a hard edge because a flat world is a thing with sides. The cut reaches + every pass the world is made of -- terrain, characters, grass, water and + the forest's beams and motes. + + **V-CURVE changes its shape.** With the bend on the world is not flat any + more -- it is a little globe curling away over its own horizon -- and a + square cut through that is a lie about what is being looked at. So the + box becomes a **ball**, and its edge becomes a **gradient** dissolving + into the sky (the same sky the flat screen has). One click of the left + stick throws the row and swaps the whole reading of the model. + + **A staged fight** ignores both shapes and cuts a vertical **pillar** + about the arena, always dissolved at the rim, framing the model to it: + the fight lifted out of the map as a floating disc. + + **The grips** take hold of the whole thing: one hand carries the model + anywhere in the room, both hands turn it and open the viewport out to + whatever you spread your hands to. The **left stick's click** throws + V-CURVE to its top rung and back rather than stepping views -- there is + no 2D diorama and no first-person one, so the ladder is held on an orbit + rung for as long as the mode runs, and the Pokedex stays away. + + **DIORAMA-MR** is the same mode with the background keyed pure green -- + no bands, no sun, no haze, because every one of those is a colour a + keyer would have to survive -- for a mixed-reality capture that + composites the model into the player's own room. + + A save that stored the old VR toggle as `true` comes back on STANDARD + rather than falling to OFF. The viewport is compiled into the scene + shader as its own variant, so a flat frame -- and a phone above all -- + builds and binds exactly what it always did. + ## 1.6.2 ### Added diff --git a/README.md b/README.md index 2d20479..1838040 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ menu. | `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row | | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | -| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | +| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 → 4 → 5 — bend the world over the horizon; 5 is a half sphere | | `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | @@ -185,13 +185,43 @@ identical. ## VR -The **VR** options row (OFF / ON, off by default) drives a PCVR headset -through OpenXR on Windows — SteamVR, Oculus or WMR. +The **VR** options row (OFF / STANDARD / DIORAMA / DIORAMA-MR, off by +default) drives a PCVR headset through OpenXR on Windows — SteamVR, +Oculus or WMR. -Both free-roam rungs put the headset in the player's *head*: a boom that -seats its wearer three cells behind their own body is a reliable way to make -people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the -walk and the sprites the same way. +**STANDARD** follows the VOXEL ladder. Both free-roam rungs put the +headset in the player's *head*: a boom that seats its wearer three cells +behind their own body is a reliable way to make people ill, so **3RD** in +VR is **1ST** in VR. The rung still changes the walk and the sprites the +same way. + +### DIORAMA + +**DIORAMA** is one presentation instead of a ladder: the world is always a +model on the table, and the model is a *thing in the room*. + +- **A viewport.** Everything outside an invisible **box** centred on the + view is not drawn — a square slab of Kanto sitting in the air rather + than a map running off to a horizon, cut with a hard edge, because a + flat world is a thing with sides and the sides are what say so. The sky + behind is the same one the flat screen has. +- **V-CURVE changes its shape.** With the bend on the world is not flat + any more, and a square cut through a little globe is a lie about what is + being looked at — so the box becomes a **ball** whose rim is a + **gradient** dissolving into the sky. One click of the left stick throws + the row and swaps between the two readings of the same model. +- **A staged fight** ignores both and cuts a vertical pillar about the + arena, always with the dissolved rim, which lifts the fight out of the + map as a floating disc. +- **The grips** take hold of it: one hand carries the model anywhere in + the room, both hands turn it and open the viewport out to whatever you + spread your hands to. +- **The left stick's click** throws **V-CURVE** to its top rung and back, + rather than stepping views — there is no 2D diorama and no first-person + one, so the ladder is held on an orbit rung while the mode runs. + +**DIORAMA-MR** is the same mode with the background keyed pure green, for +a mixed-reality capture that composites the model into your own room. ### VR controls @@ -204,10 +234,11 @@ alongside. | left stick | move — grid-walks the diorama, free-walks 1ST | | A / B (X / Y on the left hand) | A / B | | either trigger | START | -| left stick click | step the VOXEL angle ladder (same as the "3" key) | -| right stick up / down | *diorama only* — zoom the model | +| left stick click | *STANDARD* — step the VOXEL angle ladder (same as the "3" key); *DIORAMA* — throw **V-CURVE** to its top rung and back | +| right stick up / down | *tabletop* — zoom the model | | right stick left / right | *1ST only* — snap-turn 45°, or turn smoothly with **SMOOTH TURN** on | -| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height | +| one grip squeezed | *STANDARD* — drag the table's height; *DIORAMA* — carry the model wherever that hand goes | +| both grips squeezed | *DIORAMA only* — turn the model with your hands, and open or close the viewport by spreading them | | head | *1ST and battles* — look; FreeMove walks where you look | | left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen | diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua index 33f7164..680cece 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 = 10, y = 16, shape = "narrow", turn = 90 }, + ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" }, ["ROUTE_2"] = { x = 1, y = 49, 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 }, + ["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" }, -- 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 = 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_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_17"] = { x = 14, y = 70, shape = "wide" }, - ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" }, + ["ROUTE_18"] = { x = 11, y = 4, 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", 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_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_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,79 +103,73 @@ return { -- is shot there ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6, shape = "wide" }, - ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" }, - ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" }, + ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" }, + ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" }, ["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 = 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" }, + ["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" }, ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, 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" }, + ["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" }, ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" }, - ["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" }, + ["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" }, -- 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 = 4, y = 9, shape = "narrow", turn = 270 }, - ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 }, + ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" }, + ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" }, ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" }, - ["SILPH_CO_6F"] = { x = 19, y = 2, - shape = "narrow", turn = 90, cam = "wide" }, + ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" }, ["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 = 11, y = 1, shape = "narrow", cam = "wide" }, + ["SS_ANNE_1F_ROOMS"] = { x = 10, 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", cam = "wide" }, - ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" }, + ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, + ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" }, - ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" }, - ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" }, + ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" }, + ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" }, -- ------- towns and the last interiors ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" }, - ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" }, + ["GAME_CORNER"] = { x = 8, y = 7, 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 = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" }, + ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", 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 = 1, shape = "wide", cam = "wide" }, - ["SILPH_CO_11F"] = { x = 10, y = 6, - shape = "wide", turn = 180, cam = "wide" }, + ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" }, + ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", 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 = 3, y = 2, - shape = "narrow", turn = 90, cam = "wide" }, - ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" }, + ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" }, + ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" }, ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" }, -- ------- the remaining routes - ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" }, + ["ROUTE_19"] = { x = 8, y = 6, 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", 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_22"] = { x = 35, y = 7, shape = "wide" }, + ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" }, + ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" }, ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" }, -- ------- caves, gyms and the Elite Four @@ -183,29 +177,25 @@ 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 = 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" }, + ["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" }, -- 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", cam = "wide" }, - ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" }, + ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" }, + ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" }, ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "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" }, + ["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" }, -- 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 @@ -214,7 +204,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 = 8, y = 6, shape = "narrow", cam = "wide" }, - ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" }, + ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" }, + ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" }, ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" }, } diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua index d3f3a4e..ee2ebc0 100644 --- a/lib/ForestAtmos.lua +++ b/lib/ForestAtmos.lua @@ -51,6 +51,20 @@ local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max local ForestAtmos = {} +-- The diorama's viewport, as both shaders here take it (see Voxel3D.cull: +-- the field is set for a headset's diorama frame and nil for every other, +-- where kind 0 means "no cut"). Read through V.require rather than held as +-- an upvalue because this file loads before Voxel3D on some paths. +local function cullAt() + local c = V.require("Voxel3D").cull + return c and { c.x, c.y, c.z } or { 0, 0, 0 } +end + +local function cullShape() + local c = V.require("Voxel3D").cull + return c and { c.r, c.invFade, c.kind } or { 0, 0, 0 } +end + -- FULL is the point; LOW halves the march and drops the particles, for -- hardware that minds a per-pixel loop under 4X supersampling. -- @@ -416,6 +430,25 @@ local RAY_SHADER = [[ uniform vec3 sunward; // unit, toward the unseen sun uniform vec2 wind; // leaf-field drift, uv per second uniform float time; + // the diorama's viewport, as the scene shader takes it (see Voxel3D): + // air outside the model is not air, so a sample out there contributes + // nothing and the beams end with the world they fall through + uniform vec3 cullAt; + uniform vec3 cullShape; + + float dioramaCull(vec3 p) { + if (cullShape.z <= 0.5) return 1.0; + vec3 cd = p - cullAt; + float d; + if (cullShape.z < 1.5) { + d = max(abs(cd.x), abs(cd.z)); // the box + } else if (cullShape.z < 2.5) { + d = length(cd); // the ball + } else { + d = length(cd.xz); // the fight's pillar + } + return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); + } float sunDepth(vec2 uv) { vec4 c = Texel(sunMap, uv); @@ -492,7 +525,8 @@ local RAY_SHADER = [[ float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0); float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0); float dens = fogW.x * exp(-y * fogW.y); - acc += trans * lit * dapple * fadeIn * foot * dens * dt; + acc += trans * lit * dapple * fadeIn * foot * dens * dt + * dioramaCull(p); } trans *= exp(-fogW.x * 0.5 * dt); } @@ -587,6 +621,8 @@ local PART_SHADER = [[ #ifdef VERTEX uniform mat4 vp; uniform vec3 curve; + uniform vec3 cullAt; // the diorama's viewport (see Voxel3D.cull): + uniform vec3 cullShape; // a mote outside the model is not in the air uniform vec3 axisR; // the camera's right, world space uniform vec3 axisU; // and its up: the billboard's own frame uniform float time; @@ -606,6 +642,20 @@ local PART_SHADER = [[ cos(t * 0.19 + ph * 1.3) * sway.x); float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0); vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky); + // a whole mote at once: these are points, so the rim can dim them + // rather than having to cut one in half + if (cullShape.z > 0.5) { + vec3 cd = base - cullAt; + float cdd; + if (cullShape.z < 1.5) { + cdd = max(abs(cd.x), abs(cd.z)); + } else if (cullShape.z < 2.5) { + cdd = length(cd); + } else { + cdd = length(cd.xz); + } + vGlow *= clamp((cullShape.x - cdd) * cullShape.y, 0.0, 1.0); + } vCorner = AtmosData.xy; vec4 w = vec4(base + axisR * (AtmosData.x * size) + axisU * (AtmosData.y * size), 1.0); @@ -763,6 +813,8 @@ function ForestAtmos.draw(map) pcall(sh.send, sh, "curve", { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, Voxel3D.curveK or 0 }) + pcall(sh.send, sh, "cullAt", cullAt()) + pcall(sh.send, sh, "cullShape", cullShape()) pcall(sh.send, sh, "screen", { w, h }) pcall(sh.send, sh, "fogW", { f.fog.density, f.fog.heightK, @@ -793,6 +845,8 @@ function ForestAtmos.draw(map) pcall(psh.send, psh, "curve", { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, Voxel3D.curveK or 0 }) + pcall(psh.send, psh, "cullAt", cullAt()) + pcall(psh.send, psh, "cullShape", cullShape()) pcall(psh.send, psh, "axisR", axisR) pcall(psh.send, psh, "axisU", axisU) pcall(psh.send, psh, "time", ForestAtmos.time) diff --git a/lib/VR.lua b/lib/VR.lua index 9ea5bb8..3682f20 100644 --- a/lib/VR.lua +++ b/lib/VR.lua @@ -20,7 +20,17 @@ -- battle or wipe is what the flat screen is showing -- xrEndFrame with the projection layer and/or the quad -- --- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit +-- WHICH VR YOU GET is the row's own rung first (see VR.setting), and only +-- then the VOXEL ladder. STANDARD is the mode described below. The two +-- DIORAMA rungs are one presentation instead of a ladder -- the world is +-- always a model on the table, cut to a square viewport (a ball with a +-- dissolved rim while V-CURVE is on) that the grips pick up, turn and +-- open out, with a staged fight arriving as a floating disc of map. +-- lib/Diorama owns all of that; what this file owns is pointing the +-- mapping at it. DIORAMA-MR is the same with the background keyed green +-- for a mixed-reality capture. +-- +-- Within STANDARD, which VR you get mirrors the VOXEL ladder: on the orbit -- 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 @@ -52,12 +62,30 @@ local VRRig = V.require("VRRig") local VRXR = V.require("VRXR") local VRGL = V.require("VRGL") local Pokedex = V.require("Pokedex") +local Diorama = V.require("Diorama") local VR = {} --- the row: plain OFF/ON. No hotkey -- the engine's display keys are --- spoken for, and a headset is not something to toggle by accident. -VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" }) +-- The row: OFF, and then WHICH VR. No hotkey -- the engine's display keys +-- are spoken for, and a headset is not something to toggle by accident. +-- +-- STANDARD what this mod shipped: the headset follows the VOXEL +-- ladder, orbit rungs becoming a tabletop and 1ST standing +-- you inside the world at life size. +-- DIORAMA one presentation instead of a ladder -- the world is +-- always a model on the table, cut to a viewport you can +-- pick up, turn and open out (see lib/Diorama). There is no +-- 2D and no first person in it: both are a different promise +-- about where the player is standing. +-- DIORAMA-MR the same, with the background keyed pure green for a +-- mixed-reality capture. +-- +-- `true` is still STANDARD's stored value, deliberately: the row used to be +-- a toggle, and a save that stored it as one must come back on the rung it +-- was left on rather than falling to OFF. +VR.setting = ModSetting.new("vr", "VR", + { false, true, "diorama", "diorama-mr" }, + { "OFF", "STANDARD", "DIORAMA", "DIORAMA-MR" }) -- How the right stick turns you in first person. OFF is the 45-degree -- SNAP this mod shipped with and the reason for it is comfort, not @@ -152,8 +180,27 @@ function VR.supported() return os == "Windows" end +-- Which VR the row is asking for: "off", "standard", "diorama" or +-- "diorama-mr". The one place the stored value is interpreted -- everything +-- else asks this, so a rung added to the ladder is a change here and +-- nowhere else. +function VR.mode() + if not VR.supported() then return "off" end + local v = VR.setting:get() + if v == true then return "standard" end + if v == "diorama" or v == "diorama-mr" then return v end + return "off" +end + function VR.enabled() - return VR.supported() and VR.setting:get() == true + return VR.mode() ~= "off" +end + +-- Whether the row is on one of the DIORAMA rungs -- the modes where the +-- world is a model with an edge to it rather than a place to stand in. +function VR.dioramaMode() + local m = VR.mode() + return m == "diorama" or m == "diorama-mr" end function VR.active() @@ -208,6 +255,9 @@ local function shutdown(reason) zoom, heightOff = 1, 0 fpYawOff, snapArmed = 0, true camMode, fadeAlpha = "explore", 0 + -- the model goes back on the table where it started: the grab, the turn, + -- the viewport's size and the meshes cut for it + Diorama.reset() status = reason or "off" end @@ -286,10 +336,43 @@ local function renderWorld(views, ctl) -- three cells behind their own body is a well-known way to make people -- ill. The rung still changes the walk and the cards the same way; only -- the eye stays where a head belongs. - local fp = FirstPerson.engaged() + -- + -- A DIORAMA mode never does either: the world is a model on the table + -- whatever the rung says, so first person is refused here rather than + -- being made to work at a scale it does not mean. + local dio = VR.dioramaMode() + local fp = (not dio) and FirstPerson.engaged() local battle, battleFloor if camMode == "battle" then battle, battleFloor = battleStage() end - if battle then + if dio then + -- ------- the diorama modes + -- + -- The model presents exactly as the standard view frames it -- the + -- pivot VIEW_DIST away along the rung's angle, at the scale that + -- reproduces that framing -- and then everything the player has done + -- to it goes on top: the carry, the turn, the stick's zoom. + -- + -- A STAGED FIGHT does not move the head here (that is the standard + -- mode's over-the-shoulder seat, and it is a first-person answer): + -- the MODEL re-centres on the arena and the viewport becomes a + -- vertical pillar about it, so the fight arrives as a disc of map + -- lifted out of the world and left floating on the table. + -- what the model is FRAMED to fill: the view the flat screen would + -- have shown ordinarily, and the DISC itself while a fight is staged + -- -- a disc left at map scale is a coin on a table across the room. + local frame = vh + if battle then + pivot = VRRig.dioramaPivot(battle.mid[1], battle.mid[2]) + local cut = Diorama.pillar(battle) + if cut then frame = cut.r * 2.6 end + else + pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2) + Diorama.viewport(pivot[1], pivot[3], vh) + end + anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset) + scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom + if Diorama.yaw ~= 0 then mountYaw = Diorama.yaw 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 -- life scale, turned to face the arena @@ -332,8 +415,11 @@ local function renderWorld(views, ctl) -- 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. + -- (`not dio` for the same reason the diorama never had one: a hand-sized + -- device hovering over a tabletop town is clutter, and that is as true + -- of a tabletop FIGHT -- the panel serves both.) local hand = ctl and ctl.handl or nil - if hand and (battle or fp) then + if hand and not dio and (battle or fp) then Pokedex.place(hand, pivot, anchor, scale, mountYaw) if uiShowing() then local scr = dexScreen() @@ -380,11 +466,25 @@ local function renderWorld(views, ctl) end end + -- THE WORLD CURVE, for the diorama modes alone. Standing inside a bent + -- world is what first person declines on the flat screen too, and the + -- battle mount is a placed shot -- but a diorama is a model being looked + -- AT, so the bend turns it into a little globe curling over its own + -- horizon, which is the whole point of the throw the left stick's click + -- makes. Measured against the FLAT view height, so a rung's bend is the + -- same bend the flat screen would have drawn. + -- + -- It bends about the scene centre, which for these eyes is the pivot -- + -- the model's own middle -- so the globe is centred on the model rather + -- than on wherever a head happens to be standing. + local curveK = dio and V.require("WorldCurve").k(vh) or 0 + local eyes = {} for i = 1, 2 do local v = views[i] eyes[i] = { - camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw), + camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw, + curveK), w = v.w, h = v.h, slot = i == 1 and "vrL" or "vrR", -- the battle seat is a placed shot, not the first-person rig: the @@ -518,6 +618,13 @@ end -- and moving that hand up or down drags the whole table -- with it. -- +-- The DIORAMA modes rebind two of those, because in them there is no +-- ladder to step and no table-height to be the only thing worth dragging: +-- +-- left stick click throws V-CURVE to its top rung and back. +-- grips one carries the model anywhere in the room; both +-- turn it and open the viewport out (Diorama.gesture). +-- -- 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. @@ -537,6 +644,61 @@ function VR.stepView() end) end +-- Put the VOXEL ladder on a given rung, for the one caller that needs to +-- rather than to step: a DIORAMA mode holding the ladder off 2D and off +-- both free-roam rungs (see dioramaRung). Handed over by main.lua next to +-- cycleVoxel and for the same reason. +VR.setVoxelLevel = nil -- setVoxelLevel(game, level), set by main.lua + +-- The rung a diorama mode holds the ladder on when it finds it somewhere +-- the mode cannot present: 35 degrees, the standard view's own angle. +VR.DIORAMA_RUNG = 3 + +-- 2D is not a diorama and neither is standing inside the world, so while a +-- diorama mode is live the ladder is held on an orbit rung. Cheap enough to +-- ask every frame: it is a table read and, almost always, no write. +local function dioramaRung() + pcall(function() + if not VR.setVoxelLevel then return end + local Pipelines = require("src.render.Pipelines") + local level = Pipelines.level("voxel") or 0 + if level == 0 or Voxel.isFreeCam(level) then + VR.setVoxelLevel(require("src.core.Game"), VR.DIORAMA_RUNG) + end + end) +end + +-- The V-CURVE row, thrown to its top rung and back -- what the left stick's +-- click does in a diorama, where there is no ladder for it to step. +-- +-- A toggle rather than a cycle, because in a headset the curve is not a +-- taste setting with four values: it is the one control that decides +-- whether the model is a flat slab of map or a little world curling away +-- over its own horizon, and the player wants to see both, now, without +-- counting clicks. The rung it was on is remembered so the click gives it +-- back rather than dropping the row to OFF. +-- +-- It changes the CUT with it (see lib/Diorama): flat world, square box, +-- hard edge; curved world, ball, dissolve. One click swaps the whole +-- reading of the model, which is why it is the click worth having here. +local curveWas = nil + +function VR.toggleCurve() + pcall(function() + local Game = require("src.core.Game") + local WorldCurve = V.require("WorldCurve") + local top = WorldCurve.setting.values[#WorldCurve.setting.values] + if WorldCurve.setting:get() == top then + WorldCurve.setting:setValue(curveWas or WorldCurve.setting.values[1], + Game) + curveWas = nil + else + curveWas = WorldCurve.setting:get() + WorldCurve.setting:setValue(top, Game) + end + end) +end + -- 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 @@ -545,7 +707,9 @@ end function VR.leave() pcall(function() local Game = require("src.core.Game") - VR.setting:setIndex(VR.setting:read() + 1, Game) + -- OFF by VALUE, not by stepping the row: the row is a ladder now, and + -- one step off STANDARD is DIORAMA rather than the way out + VR.setting:setValue(false, Game) end) end @@ -559,9 +723,10 @@ local function setGB(inp, btn, down) end end -local function driveControls(ctl, dt, fp) +local function driveControls(ctl, dt, fp, dio) if not ctl then releaseInputs() + Diorama.releaseGrab() return end local ok, Game = pcall(require, "src.core.Game") @@ -595,11 +760,18 @@ local function driveControls(ctl, dt, fp) inp:gamepadaxis(nil, "leftx", ctl.moveX or 0) inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0)) - -- the left stick click: the VOXEL ladder ordinarily, and the way out of - -- horde mode while it runs (the rung is locked there, so the click has - -- nothing else to do, and a headset has no ESCAPE key) + -- the left stick click: the VOXEL ladder ordinarily, the V-CURVE throw in + -- a diorama (where the ladder is held on one rung and the click would + -- otherwise do nothing), and the way out of horde mode while it runs (the + -- rung is locked there too, and a headset has no ESCAPE key) if ctl.toggleChanged and ctl.toggle then - if Horde.active then Horde.askExit() else VR.stepView() end + if Horde.active then + Horde.askExit() + elseif dio then + VR.toggleCurve() + else + VR.stepView() + end end -- first person's turn on the right stick. SMOOTH TURN ON makes it a @@ -634,6 +806,20 @@ local function driveControls(ctl, dt, fp) end end + -- THE DIORAMA'S GRIPS take the model itself: one hand carries it through + -- the room, both turn it and open the viewport out (see Diorama.gesture). + -- The stick's zoom still sizes the model under all of that -- the two + -- are different questions, "how big is it" and "how much of it is there". + if dio then + lastHandY = nil + 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 + Diorama.gesture(ctl) + return + end + if not fp and camMode ~= "battle" then local zy = ctl.lookY or 0 if math.abs(zy) > 0.15 then @@ -659,7 +845,8 @@ end -- ------- the per-frame drive function VR.update(dt) - local on = VR.enabled() + local mode = VR.mode() + local on = mode ~= "off" if not on then if wasOn then shutdown("off") @@ -703,6 +890,12 @@ function VR.update(dt) pcall(love.window.setVSync, 0) end + -- Which VR this frame is, before anything reads it: the diorama's own + -- fields (the viewport, the chroma key) are open for the length of the + -- frame and shut with the session. The rung guard rides it -- there is + -- no 2D diorama and no first-person one. + if Diorama.begin(mode) then dioramaRung() end + -- the battle camera holds still for as long as a headset is watching: -- its drift is a flat screen's depth cue, and a swaying picture inside -- VR reads as the world lurching @@ -727,8 +920,9 @@ function VR.update(dt) -- 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 dio = VR.dioramaMode() local ctl = VRXR.input(time) - driveControls(ctl, dt, FirstPerson.engaged()) + driveControls(ctl, dt, (not dio) and FirstPerson.engaged(), dio) local worldUp = false if should then @@ -737,7 +931,9 @@ function VR.update(dt) worldUp = renderWorld(views, ctl) end end - local quadPose = updateQuad(worldUp, FirstPerson.engaged()) + -- the diorama's panel is the tabletop one whatever the rung says: there + -- is no first person in the mode to float it closer for + local quadPose = updateQuad(worldUp, (not dio) and FirstPerson.engaged()) VRXR.endFrame(time, worldUp or nil, quadPose) end @@ -778,6 +974,7 @@ function VR.invalidate() if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end dexCanvas = nil Pokedex.invalidate() + Diorama.invalidate() -- the base's mesh and its cave-floor texture V.require("HordeGun").invalidate() V.require("HordeHud").invalidate() for k in pairs(fboCache) do fboCache[k] = nil end diff --git a/lib/VRRig.lua b/lib/VRRig.lua index 7b38c5e..fc9b19d 100644 --- a/lib/VRRig.lua +++ b/lib/VRRig.lua @@ -62,13 +62,25 @@ VRRig.VIEW_DIST = 0.95 -- and (-d sin a) ahead of the resting head reproduces exactly that line -- of sight -- step onto the 35 rung and the table presents at 35 degrees, -- onto 75 and it rises toward eye level, easing between them as the rung --- tween runs. `heightOff` is the grab-drag adjustment, in metres of world --- travel (positive drags the world up). -function VRRig.dioramaAnchor(angleRad, heightOff) +-- tween runs. +-- +-- `off` is the grab-drag adjustment, in metres of LOCAL travel -- where +-- the player has carried the model to. A bare number is the height alone, +-- which is what the standard mode's one-axis drag has always sent; the +-- DIORAMA modes hand over all three (see lib/Diorama). Positive Y drags +-- the world up: the anchor is the LOCAL point pinned to the pivot, so +-- moving it moves the model with the hand rather than against it. +function VRRig.dioramaAnchor(angleRad, off) local d = VRRig.VIEW_DIST - return { 0, - -d * math.cos(angleRad or 0) + (heightOff or 0), - -d * math.sin(angleRad or 0) } + local ox, oy, oz = 0, 0, 0 + if type(off) == "table" then + ox, oy, oz = off[1] or 0, off[2] or 0, off[3] or 0 + elseif type(off) == "number" then + oy = off + end + return { ox, + -d * math.cos(angleRad or 0) + oy, + -d * math.sin(angleRad or 0) + oz } end -- The diorama's scale, in world px per metre: the one that makes the @@ -133,12 +145,26 @@ VRRig.FAR = 400 -- 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). +-- curveK the world curve this eye is to be drawn with (see WorldCurve); +-- omitted is 0, the curve DECLINED. +-- +-- Off by default because standing inside a bent world is what first person +-- already declines on the flat screen, and the battle mount is a placed +-- shot. The DIORAMA modes are the case that wants it and asks for it: the +-- model is a thing being looked AT, so bending it into a little globe is +-- the whole point rather than a broken tabletop -- and it is what the left +-- stick's click throws (see lib/VR). Passed in rather than read here +-- because a rig has no business deciding what a row means. +-- +-- Beware the shape of the answer: Voxel3D reads `camera.curve` with `or`, +-- and 0 is TRUE in Lua, so a 0 here really does pin the bend off -- which +-- is exactly why the diorama's curve did nothing until this became a +-- parameter. -- -- 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, yaw) +-- vertical span, and that curve. +function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw, curveK) 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]) @@ -199,7 +225,7 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw) eye = { ex, ey, ez }, focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale }, fov = fov.angleUp - fov.angleDown, - curve = 0, + curve = curveK or 0, skyRay = skyRay, } end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index c7e5df5..d7dcefc 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -73,6 +73,13 @@ local SHADER = [[ varying float vShade; varying vec3 vSun; // this fragment's place in the sun's view varying float vFog; // how deep into the map's haze it stands +#ifdef VOXEL_CULL + // where this fragment stands in the FLAT world, for the diorama's + // viewport to measure. Same precision reasoning as vGrid below: a + // route's coordinates run to a few thousand and mediump has no + // fraction left out there, which would make the rim crawl. + varying LOVE_HIGHP_OR_MEDIUMP vec3 vWorld; +#endif #ifdef VOXEL_GRID // model space, one unit per voxel -- see VoxelGrid. Precision matters // here in a way it does not for a colour: the seam is the FRACTIONAL @@ -123,6 +130,19 @@ local SHADER = [[ vFog = (1.0 - exp(-fogInfo.x * fogRun)) * exp(-max(w.y, 0.0) * fogInfo.z); } +#ifdef VOXEL_CULL + // THE DIORAMA'S VIEWPORT (see lib/Diorama) is measured per FRAGMENT, + // so this stage's only job is to hand the position over -- and to hand + // over the FLAT one, like the fog and the shadow lookup above: the + // curve is a trick played on the viewer, and letting it drag geometry + // in and out of the viewport would make the rim breathe with the bend. + // + // Per fragment rather than per vertex because the diorama's own base + // is cut into cells far coarser than the rim is wide, and interpolating + // the rim across one of those spilled a whole cell of ground past the + // edge of a staged fight's disc. + vWorld = w.xyz; +#endif // The curved world (see WorldCurve): drop every vertex by the square // of how far its column stands from the camera's focus. Applied AFTER // the shadow lookup above and clear of the wireframe's model space, so @@ -147,6 +167,37 @@ local SHADER = [[ } #endif #ifdef PIXEL +#ifdef VOXEL_CULL + // The viewport, declared in THIS STAGE ALONE. A uniform declared in both + // defaults to highp in the vertex stage and mediump here, and GLSL ES + // refuses to link a uniform the two stages disagree about -- which is + // not a broken cut but no scene shader at all (lib/Water states the same + // trap at length for `vp`). + uniform vec3 cullAt; // the viewport's centre, in world pixels + uniform vec3 cullShape; // half-size, 1/fade, kind: 1 box, 2 ball, + // 3 the staged fight's pillar + + // 1 well inside the viewport, 0 outside it, and the rim in between -- + // which is a HARD edge for the box (its band is half a pixel wide, so + // the ramp is just the antialiasing) and a dissolve for the other two. + // + // The box and the pillar are unbounded upward and downward on purpose: + // what is wanted is a square (or round) piece cut OUT OF THE MAP, and a + // cut with a lid would take the tops off the trees standing in it. + float dioramaCull(vec3 p) { + if (cullShape.z <= 0.5) return 1.0; + vec3 cd = p - cullAt; + float d; + if (cullShape.z < 1.5) { + d = max(abs(cd.x), abs(cd.z)); // the box: a square of map + } else if (cullShape.z < 2.5) { + d = length(cd); // the ball, under V-CURVE + } else { + d = length(cd.xz); // the fight's pillar + } + return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); + } +#endif uniform Image sunMap; uniform float sunDark; // how far into black a shadow goes; 0 = off uniform float sunBias; @@ -234,6 +285,15 @@ local SHADER = [[ // blending keeps those texels out of the depth buffer, so a model never // carves a transparent hole out of whatever stands behind it if (p.a < 0.5) discard; + // and the same for anything the diorama's viewport has faded out + // entirely: past the rim there is no world, and a fully faded fragment + // that still wrote depth would punch a hole in the sky behind it +#ifdef VOXEL_CULL + float cull = dioramaCull(vWorld); + if (cull <= 0.0) discard; +#else + float cull = 1.0; +#endif // the hour's tint multiplies like the sun terms do: it is LIGHT, the // same warm or moonlit cast on every surface, not a palette swap vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint; @@ -284,17 +344,33 @@ local SHADER = [[ // solid silhouette. Last in the chain, so neither the sun nor a voxel // seam can mottle it. rgb = mix(rgb, ghostColor, ghost); - return vec4(rgb, 1.0) * color; + // the viewport's rim is an ALPHA, so the last of the model blends into + // whatever the frame opened with -- the sky, or the chroma key. 1 + // everywhere without the cut compiled in, which is every flat frame. + return vec4(rgb, cull) * color; } #endif ]] --- Two compilations of SHADER: the plain scene, and the same thing with the --- voxel wireframe compiled in. The wireframe needs shader derivatives --- (fwidth), the one piece of this a driver can refuse, so it is a separate --- build rather than a branch -- a refusal costs the grid and nothing else. +-- Compilations of SHADER, by what is compiled INTO it: the voxel +-- wireframe, and the diorama's viewport. Variants rather than branches, +-- for two different reasons. +-- +-- The wireframe needs shader derivatives (fwidth), the one piece of this a +-- driver can refuse, so a refusal has to cost the grid and nothing else. +-- +-- The viewport carries a world-position varying, and a varying is paid for +-- by every fragment of every frame whether or not anything reads it. The +-- cut only ever exists inside a headset's diorama, so every other frame -- +-- the flat screen, and a phone above all -- compiles and binds exactly +-- what it always did. +-- -- Each entry is nil = untried, false = unavailable. -local shaders = { [false] = nil, [true] = nil } +local shaders = {} + +local function shaderKey(grid, cull) + return (grid and "grid" or "plain") .. (cull and "+cull" or "") +end local activeShader = nil -- the variant this pass bound -- Scene canvases, one per NAMED SLOT. There are exactly two callers and @@ -377,21 +453,24 @@ local function derivativesOK() return ok and caps and caps.shaderderivatives == true end --- The scene shader. `grid` asks for the wireframe variant, and nil comes --- back when that one will not build -- callers then fall back to the plain --- one rather than losing the whole 3D pass. -function Voxel3D.shader(grid) - grid = grid and true or false - if shaders[grid] == nil then +-- The scene shader. `grid` asks for the wireframe variant and `cull` for +-- the diorama's viewport; nil comes back when that combination will not +-- build -- callers then fall back to a plainer one rather than losing the +-- whole 3D pass. +function Voxel3D.shader(grid, cull) + grid, cull = grid and true or false, cull and true or false + local key = shaderKey(grid, cull) + if shaders[key] == nil then if grid and not derivativesOK() then - shaders[grid] = false + shaders[key] = false else - local src = grid and ("#define VOXEL_GRID 1\n" .. SHADER) or SHADER + local src = (grid and "#define VOXEL_GRID 1\n" or "") + .. (cull and "#define VOXEL_CULL 1\n" or "") .. SHADER local ok, sh = pcall(love.graphics.newShader, src) - shaders[grid] = ok and sh or false + shaders[key] = ok and sh or false end end - return shaders[grid] or nil + return shaders[key] or nil end -- Whether the 3D path can run at all. False on a headless test run (no @@ -716,6 +795,22 @@ Voxel3D.tint = { 1, 1, 1 } -- last one's weather. Voxel3D.fog = nil +-- THE DIORAMA'S VIEWPORT, set the same way (VoxelScene asks lib/Diorama, +-- who is told by lib/VR what the headset is doing): a table of +-- { x, y, z, r, invFade, kind }, kind 1 for the ball and 2 for the staged +-- fight's pillar. nil -- the default, and what every flat frame leaves it +-- at -- sends kind 0, which is the shader's "draw the whole world". +-- +-- A plain field rather than a require of lib/Diorama, and deliberately: +-- this file is the bottom of the stack and everything else in the mode is +-- built on it, so it learns about the diorama the same way it learns about +-- the weather and the hour -- by being handed the answer. +Voxel3D.cull = nil + +-- What the background is cleared to INSTEAD of the sky, or nil for the +-- sky: DIORAMA-MR's chroma key, set for the eye passes alone. +Voxel3D.keyColor = nil + -- The window-glass pass, set the same way and for the same reason: the -- MASK belongs to the map's tileset (GlassMask.texture) and how lit the -- panes are belongs to the hour and to being outdoors at all @@ -839,12 +934,20 @@ end -- `slot` names which cached canvas to render into (see `slots` above); -- omitted is the free-roam world pass. function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) - -- the wireframe variant when the player has it on AND it built; either - -- answer falls through to the plain scene rather than to no scene + -- the wireframe variant when the player has it on AND it built, and the + -- viewport variant while a diorama frame is open; either answer falls + -- through to a plainer scene rather than to no scene. The cut is dropped + -- LAST, because losing it draws a whole uncut world where a model should + -- be, which is worse than losing the seams. local grid = VoxelGrid.enabled() - local sh = grid and Voxel3D.shader(true) or nil + local cut = Voxel3D.cull ~= nil + local sh = grid and Voxel3D.shader(true, cut) or nil if not sh then - grid, sh = false, Voxel3D.shader() + grid = false + sh = Voxel3D.shader(false, cut) + end + if not sh and cut then + cut, sh = false, Voxel3D.shader(false, false) end if not sh then return false end local name = slot or "world" @@ -893,10 +996,18 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- pitch is the rung's -- the classic frame-hung painting stands. local skyRay = Voxel3D.skyRayLive local hy = Voxel3D.horizonY(h) + -- DIORAMA-MR: the background is a CHROMA KEY, so there is no sky at all + -- -- not a green one painted over, but no bands, no disc and no haze, + -- because every one of those is a colour a keyer would have to survive. + -- The world itself is untouched; only what is behind it changes. + local key = Voxel3D.keyColor + if key then sky = nil end -- 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, hy) or nil - if sky then + if key then + love.graphics.clear(key[1], key[2], key[3], 1, true, true) + elseif 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 -- rectangle is just a rectangle: the depth mode and the scene shader are @@ -957,6 +1068,13 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "fogInfo", fog and { fog.density or 0, fog.start or 0, fog.heightK or 0, 0 } or { 0, 0, 0, 0 }) + -- and the diorama's viewport (see Voxel3D.cull), kind 0 when there is + -- none -- which is every frame that is not a headset's diorama + local cull = Voxel3D.cull + pcall(sh.send, sh, "cullAt", + cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 }) + pcall(sh.send, sh, "cullShape", + cull and { cull.r, cull.invFade, cull.kind } or { 0, 0, 0 }) -- the window glass: the tileset's mask (or the blank -- the sampler is -- declared either way, and unbound is a driver-dependent crash), how lit -- the panes are, and the movement-fed glint as the caller last set it diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 907408b..85fcddd 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -27,6 +27,7 @@ local DayNight = V.require("DayNight") local FirstPerson = V.require("FirstPerson") local BattleBillboard = V.require("BattleBillboard") local Pokedex = V.require("Pokedex") +local Diorama = V.require("Diorama") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -920,6 +921,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local ForestAtmos = V.require("ForestAtmos") local atmos = ForestAtmos.frame(state.map) Voxel3D.fog = atmos and atmos.fog or nil + -- and the DIORAMA modes' viewport and chroma key (lib/Diorama, driven by + -- the headset -- lib/VR sets them for the length of one frame). Both are + -- put back to nil at the end of this function, so no other pass in the + -- frame -- the battle screen's own arena shot above all -- can inherit a + -- cut world or a green background. + local dioFrame = (eyes and Diorama.on) and true or false + Voxel3D.cull = dioFrame and Diorama.cull or nil + Voxel3D.keyColor = dioFrame and Diorama.keyColor() or nil local function atlasFor(map) return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) @@ -1199,12 +1208,19 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) end -- drawScene + -- the two diorama fields are this function's for the length of this + -- function, whichever way it leaves (see where they are set) + local function done(result) + Voxel3D.cull, Voxel3D.keyColor = nil, nil + return result + end + if not eyes then if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then - return nil + return done(nil) end drawScene() - return Voxel3D.endScene() + return done(Voxel3D.endScene()) end -- The VR frame: the same scene once per eye, each into its own named @@ -1219,12 +1235,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) if eye.adopt then FirstPerson.adoptVReye(eye.camera) end if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh, skyFor(state.map), eye.slot) then - return nil + return done(nil) end drawScene() out[i] = Voxel3D.endScene() end - return out + return done(out) end return VoxelScene diff --git a/lib/Water.lua b/lib/Water.lua index ed761df..d11a6c5 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -463,6 +463,27 @@ uniform float pxAngle; // radians of view one screen pixel subtends // sample to get back to the screen. Declared in both stages, like `vp`, and // both are highp here. uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off +// The diorama's viewport, exactly as the scene shader takes it: centre in +// world pixels, then half-size / one-over-fade / kind (0 off, 1 box, 2 +// ball, 3 the staged fight's pillar). Water is world like anything else, +// and a lake left lying outside the model would be the one thing floating +// in the sky. +uniform vec3 cullAt; +uniform vec3 cullShape; + +float dioramaCull(vec3 p) { + if (cullShape.z <= 0.5) return 1.0; + vec3 cd = p - cullAt; + float d; + if (cullShape.z < 1.5) { + d = max(abs(cd.x), abs(cd.z)); + } else if (cullShape.z < 2.5) { + d = length(cd); + } else { + d = length(cd.xz); + } + return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); +} // How far the bend has pushed the world down at world XZ `q` -- the vertex // stage's own displacement, as a number this stage can add and subtract. @@ -1089,7 +1110,14 @@ vec4 effect(EFFECT_PREC vec4 color, Image tex, EFFECT_PREC vec2 tc, #ifdef VOXEL_GRID rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis); #endif - return vec4(rgb, 1.0) * color; + // and the diorama's rim, over the finished surface. Per FRAGMENT here, + // where the scene shader answers per vertex: this stage already carries + // the world position it marched with, so the exact answer is free -- + // and measured on the FLAT world, which is what bendDrop puts back. + float cull = dioramaCull(vec3(vBent.x, vBent.y + bendDrop(vBent.xz), + vBent.z)); + if (cull <= 0.0) discard; + return vec4(rgb, cull) * color; } #endif ]] @@ -1246,6 +1274,12 @@ function Water.begin(ctx) send("vp", "row", ctx.vp) send("eye", ctx.eye) send("curve", ctx.curve) + -- the diorama's viewport, as beginScene sent it to the scene shader; + -- kind 0 -- every frame that is not a headset's diorama -- is "no cut" + local cull = V.require("Voxel3D").cull + send("cullAt", cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 }) + send("cullShape", cull and { cull.r, cull.invFade, cull.kind } + or { 0, 0, 0 }) send("screen", { ctx.screen[1], ctx.screen[2] }) send("cell", math.max(1, ctx.cell or 1)) -- how much of the view one screen pixel is worth: what sets the relief diff --git a/lib/WorldCurve.lua b/lib/WorldCurve.lua index 7abc6f7..f72a13b 100644 --- a/lib/WorldCurve.lua +++ b/lib/WorldCurve.lua @@ -56,11 +56,28 @@ WorldCurve.LABEL = "V-CURVE" -- of the town -- which stops being a look and starts being an occlusion -- bug, since what has rolled away is still there to walk into. (The first -- cut ran 0.18/0.35/0.60 and every rung of it was a marble.) -WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18 } +-- +-- 4 AND 5 ARE PAST THAT LINE ON PURPOSE, and they are for the DIORAMA: +-- once the world is a model being looked at from outside rather than a +-- place being walked around in, "the horizon has closed over the next +-- block" stops being a bug and becomes the entire effect -- the town on +-- top of a little planet. +-- +-- 5 is the HALF SPHERE, and it is not eyeballed. The drop is a parabola, +-- y = k d^2 with k = amount / vh, and the parabola that osculates a sphere +-- of radius R at its pole is y = d^2 / 2R -- so k = 1 / 2R, and an amount +-- of 1.0 gives R = vh / 2. The diorama's box is cut at exactly half a view +-- height (Diorama.BOX_FRAC), so at amount 1.0 the model's own rim is that +-- sphere's EQUATOR: the ground turns 45 degrees by the edge of the cut and +-- is falling vertically a view-height out. A dome, ending where the model +-- ends. 4 is the step between it and 3, geometrically rather than +-- arithmetically -- the effect goes as the square of distance, so even +-- steps in `amount` would bunch the whole ladder at the bottom. +WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18, 0.42, 1.00 } WorldCurve.setting = ModSetting.new(WorldCurve.KEY, WorldCurve.LABEL, - { 0, 1, 2, 3 }, - { "OFF", "1", "2", "3" }) + { 0, 1, 2, 3, 4, 5 }, + { "OFF", "1", "2", "3", "4", "5" }) function WorldCurve.level() return WorldCurve.setting:get() or 0 diff --git a/main.lua b/main.lua index 28f41b5..25f1e3a 100644 --- a/main.lua +++ b/main.lua @@ -426,7 +426,13 @@ end local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, - "Bend the world down over the horizon, Animal Crossing style." }, + "Bend the world down over the horizon, Animal Crossing style. 1 is a " + .. "hint of roll at the frame edges and 2 is the classic read; 3 is as " + .. "far as it goes before the horizon closes over ground you can still " + .. "walk into. 4 and 5 are past that on purpose and they are for a " + .. "headset's DIORAMA, where the world is a model being looked at " + .. "rather than walked around in -- 5 curls it into a half sphere, a " + .. "town on top of its own little planet." }, { Water.setting, "Reflections on water. FULL adds screen-space reflections of the " .. "shoreline, the trees and the buildings behind it; SKY is the sky, " @@ -496,9 +502,18 @@ local SETTINGS = { -- `full` for the same reason as AA: not a knob on the look, a question -- about the hardware on the desk. { VR.setting, - "PCVR through OpenXR (SteamVR, Oculus, WMR). The diorama becomes a " - .. "tabletop model your head moves around; the 1ST rung stands you " - .. "inside the world at life size, looking where the headset looks. " + "PCVR through OpenXR (SteamVR, Oculus, WMR). STANDARD follows the VOXEL " + .. "ladder: the orbit rungs become a tabletop model your head moves " + .. "around, and the 1ST rung stands you inside the world at life size, " + .. "looking where the headset looks. DIORAMA is one presentation " + .. "instead -- the world always a model, cut to a square viewport you " + .. "grab with the grips to carry, turn and open out, with a " + .. "fight arriving as a floating disc of the map. There is no 2D and " + .. "no first person in it, and the left stick's click throws V-CURVE " + .. "to its top rung and back -- which turns the square cut into a ball " + .. "with a dissolved rim, because a bent world has no straight sides. " + .. "DIORAMA-MR is the same with the background keyed green, for a " + .. "mixed-reality capture. " .. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime " .. "and the mod running from a real folder; without them the row stays " .. "and the game stays flat, with the reason on the console.", @@ -515,7 +530,10 @@ local SETTINGS = { .. "world past a head that did not move, which is the most reliable way " .. "to make somebody ill in a headset. Turn it on if you have your sea " .. "legs and want the continuity.", - when = function() return VR.enabled() end, full = true }, + -- and only under STANDARD: the stick turns a HEAD, and neither diorama + -- mode has the player standing in the world to be turned + when = function() return VR.enabled() and not VR.dioramaMode() end, + full = true }, } local schema = {} @@ -601,10 +619,29 @@ local function cycleVoxel(game) return true end +-- The same, to a NAMED rung rather than one step on: what a diorama mode +-- holds the ladder with, since 2D and both free-roam rungs are things it +-- cannot present (see VR.setVoxelLevel). Everything after the setLevel is +-- the engine work above, for the same reasons. +local function setVoxelLevel(game, level) + local Pipelines = require("src.render.Pipelines") + if Horde.viewLocked() then return false end + if Pipelines.level("voxel") == level then return false end + Pipelines.setLevel("voxel", level) + Pipelines.syncOptions(game.save.options) + game.save.options.tilt = 0 + game.save.options.gbcfx = 0 + require("src.render.GBCFX").setLevel(0) + require("src.render.Tilt").setLevel(game.save.options.tilt or 0) + game:writeOptions() + return true +end + -- The VR stick click makes this same step (VR.stepView): the function is -- a local of this file, so the handoff is explicit rather than a -- reimplementation drifting out of date in lib/VR.lua. VR.cycleVoxel = cycleVoxel +VR.setVoxelLevel = setVoxelLevel do local Game = require("src.core.Game") diff --git a/manifest.json b/manifest.json index 8a32961..6be82e2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.6.2", + "version": "1.7.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index 93e0c27..447ff57 100644 --- a/mod.card +++ b/mod.card @@ -36,8 +36,9 @@ return { "3D-BTL on hotkey 8 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing", "the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), fainting plays the faint and holds there, and a send-out grows the Pokemon out of the ball and plays the entrance. Damage plays nothing -- the set has no reaction animation in it, and the engine's own flash, blink and HP drain already say so. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", - "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", - "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job", + "VR options row (OFF / STANDARD / DIORAMA / DIORAMA-MR, off by default; a save that stored the old toggle as true comes back on STANDARD). STANDARD is the mode below. DIORAMA is one presentation instead of a ladder: the world is always the model on the table, cut to an invisible BOX centred on the view -- a square slab of world with a HARD edge, because a flat world is a thing with sides -- which V-CURVE turns into a BALL whose rim is a gradient fade into the same sky (the cut reaches terrain, cast, grass, water and the forest's beams alike), with a staged fight ignoring both and cutting a vertical PILLAR about the arena -- always dissolved at the rim -- and framing the model to it: the fight lifted out of the map as a floating disc. The grips take hold of it: one hand carries the model through the room, both hands turn it and open the viewport out. The left stick's click throws V-CURVE to its top rung and back instead of stepping views; there is no 2D diorama and no first-person one, so the VOXEL ladder is held on an orbit rung while the mode runs and the Pokedex stays away. DIORAMA-MR is the same with the background keyed pure green (no bands, no sun, no haze) for a mixed-reality capture", + "VR STANDARD (the row's second rung): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", + "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the tabletop the right stick zooms; under STANDARD a squeezed grip drags the table's height, and in a DIORAMA the grips take the model itself -- one carries it, both turn it and resize the viewport, and the left stick's click throws V-CURVE instead; no controller button leaves VR -- that is the VR row's job", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 0a7487c..977333f 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -451,20 +451,19 @@ T.eq(settingGame.mods.modOptions.DRAMATIC_SHAPE.grid, true, grid.step(settingGame) T.eq(grid.value(), "OFF", "stepping again toggles it back") --- the curve is a four-rung ladder rather than a toggle, and wraps +-- the curve is a six-rung ladder rather than a toggle, and wraps curve.step(settingGame, 1) T.eq(curve.value(), "1", "stepping the curve climbs its ladder") T.eq(settingGame.save.options.modOptions.DRAMATIC_SHAPE.curve, 1, "the curve level persists alongside the grid, not over it") T.eq(settingGame.save.options.modOptions.DRAMATIC_SHAPE.grid, false, "and the grid it shares a bucket with is untouched") -curve.step(settingGame, 1) -curve.step(settingGame, 1) -T.eq(curve.value(), "3", "the ladder reaches its top rung") +for _ = 1, 4 do curve.step(settingGame, 1) end +T.eq(curve.value(), "5", "the ladder reaches its top rung") curve.step(settingGame, 1) T.eq(curve.value(), "OFF", "and wraps back to OFF") curve.step(settingGame, -1) -T.eq(curve.value(), "3", "stepping down from OFF wraps to the top") +T.eq(curve.value(), "5", "stepping down from OFF wraps to the top") curve.step(settingGame, 1) -- the strength scales with the view height, so a rung looks the same at @@ -474,9 +473,25 @@ T.eq(WorldCurve.k(154), 0, "an OFF curve bends nothing") curve.step(settingGame, 1) T.check(math.abs(WorldCurve.k(154) - WorldCurve.AMOUNTS[2] / 154) < 1e-9, "rung 1's coefficient is its amount over the view height") -T.check(WorldCurve.AMOUNTS[2] < WorldCurve.AMOUNTS[3] - and WorldCurve.AMOUNTS[3] < WorldCurve.AMOUNTS[4], - "the ladder climbs") +for i = 2, #WorldCurve.AMOUNTS do + T.check(WorldCurve.AMOUNTS[i] > WorldCurve.AMOUNTS[i - 1], + "the ladder climbs at rung " .. (i - 1)) +end +-- rung 5 is the HALF SPHERE, and that is arithmetic rather than taste: the +-- parabola y = k d^2 osculates a sphere of radius R at its pole when +-- k = 1 / 2R, so an amount of 1 puts the sphere's radius at half a view +-- height -- which is exactly where the diorama's box is cut, so the +-- model's own rim is that sphere's equator +T.eq(WorldCurve.AMOUNTS[#WorldCurve.AMOUNTS], 1.0, + "the top rung's amount is 1 -- the half sphere") +do + local Dio = run.loader.exports.DRAMATIC_SHAPE.lib.require("Diorama") + local vh = 288 + local kTop = WorldCurve.AMOUNTS[#WorldCurve.AMOUNTS] / vh + T.check(math.abs(1 / (2 * kTop) - vh * Dio.BOX_FRAC) < 1e-6, + "whose radius is the diorama's own half-size: the model ends where " + .. "the dome does") +end T.check(math.abs(WorldCurve.k(308) * 2 - WorldCurve.k(154)) < 1e-9, "halving the zoom halves the coefficient, so the bend looks the same") -- the CPU copy Voxel3D.project uses must agree with the shader's quadratic @@ -488,9 +503,7 @@ T.check(math.abs(WorldCurve.drop(k, 0, 0, 3, 4) - 25 * k) < 1e-9, T.check(WorldCurve.drop(k, 0, 0, 20, 0) > 4 * WorldCurve.drop(k, 0, 0, 10, 0) - 1e-9, "and accelerates, so the far edge rolls away faster than the near one") -curve.step(settingGame, 1) -curve.step(settingGame, 1) -curve.step(settingGame, 1) +for _ = 1, 5 do curve.step(settingGame, 1) end T.eq(curve.value(), "OFF", "the curve is left off for the rows below") -- ------- AA renders the pass larger and folds it back down @@ -5206,6 +5219,187 @@ T.eq(type(VRMod.leave), "function", end vrRigSection() +-- ------- the DIORAMA modes +-- +-- The VR row is a ladder now -- OFF / STANDARD / DIORAMA / DIORAMA-MR -- +-- and the two diorama rungs are one presentation rather than a follow-on +-- from the VOXEL ladder: a viewport that cuts the world to a ball (a +-- pillar while a fight is staged), a base a tile deep under it, and grips +-- that carry, turn and resize the whole thing. +;(function() + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local VRM = lib.require("VR") + local D = lib.require("Diorama") + local V3 = lib.require("Voxel3D") + local Rig = lib.require("VRRig") + + -- ------- the row + T.eq(#VRM.setting.values, 4, "the VR row carries four rungs") + T.eq(VRM.setting.values[2], true, + "STANDARD is still stored as `true` -- a save written when the row was " + .. "a toggle comes back on the rung it was left on") + T.eq(VRM.setting.labels[3] .. "/" .. VRM.setting.labels[4], + "DIORAMA/DIORAMA-MR", "and the two diorama rungs are labelled") + VRM.setting:sync(false) + T.eq(VRM.mode(), "off", "OFF is off") + VRM.setting:sync(true) + T.eq(VRM.mode(), "standard", "the stored true reads as STANDARD") + T.eq(VRM.dioramaMode(), false, "which is not a diorama") + VRM.setting:sync("diorama") + T.eq(VRM.mode(), "diorama", "and the diorama rungs read as themselves") + T.check(VRM.enabled() and VRM.dioramaMode(), "both on and a diorama") + VRM.setting:sync("diorama-mr") + T.check(VRM.dioramaMode(), "MR is a diorama too") + T.eq(VRM.setting:get(), "diorama-mr", "under its own stored value") + + -- ------- the frame's own switches + T.eq(D.begin("standard"), false, "STANDARD opens no diorama frame") + T.eq(D.keyColor(), nil, "and keys nothing") + T.eq(D.begin("diorama"), true, "DIORAMA opens one") + T.eq(D.keyColor(), nil, "and still keys nothing -- it has a sky") + T.eq(D.begin("diorama-mr"), true, "MR opens one as well") + T.eq(D.keyColor()[2], 1, "and keys the background PURE green") + T.check(D.keyColor()[1] == 0 and D.keyColor()[3] == 0, + "no red and no blue in it: a keyer wants the colour the world cannot be") + + -- ------- the viewport + -- + -- A square BOX with a hard edge while the world is flat, and the ball + -- with the dissolved rim while V-CURVE bends it -- one click of the + -- left stick throws the row and swaps the whole reading. + local Curve = lib.require("WorldCurve") + local curveWas = Curve.setting:get() + Curve.setting:sync(0) + local box = D.viewport(100, 200, 288) + T.eq(box.kind, D.BOX, "a flat world is cut by a square BOX") + T.check(box.x == 100 and box.z == 200, "centred on the view") + T.check(math.abs(box.r - 288 * D.BOX_FRAC) < 1e-6, + "spanning exactly the view the standard rung would have framed, so " + .. "the zoom rows keep meaning what they mean") + T.check(1 / box.invFade <= 0.5, + "with a HARD edge: a flat world is a thing with sides") + T.eq(D.fadeFor(100, false), 0, "and no fade band to speak of") + Curve.setting:sync(3) + local ball = D.viewport(100, 200, 288) + T.eq(ball.kind, D.BALL, "V-CURVE turns the box into a BALL") + T.check(math.abs(1 / ball.invFade - ball.r * D.FADE_FRAC) < 1e-6, + "whose rim is a gradient fade into the sky rather than an edge") + T.check(ball.r > box.r, + "and which reaches further than the square did, so the throw curls " + .. "the same model rather than shaving its corners off") + local pillar = D.pillar({ mid = { 64, 96 }, + player = { 64, 120 }, enemy = { 64, 72 } }) + T.eq(pillar.kind, D.PILLAR, "a staged fight cuts a vertical PILLAR instead") + T.check(pillar.x == 64 and pillar.z == 96, "about the arena's midpoint") + T.check(math.abs(pillar.r - (24 + D.ARENA_APRON)) < 1e-6, + "wide enough for both mons and an apron -- the floating disc") + T.eq(D.pillar(nil), nil, "and no arena cuts nothing") + Curve.setting:sync(0) + local flatFight = D.pillar({ mid = { 0, 0 } }) + T.eq(flatFight.kind, D.PILLAR, + "the fight stays round whatever the curve is doing: a square tile " + .. "floating in the air is not the picture") + T.check(1 / flatFight.invFade > 1, + "and keeps its dissolve with the curve off too -- a hard edge on " + .. "something hanging in the air reads as a cookie cutter") + Curve.setting:sync(curveWas) + + -- the shader takes the volume through a plain field, so no pass can be + -- surprised by a cut world (Voxel3D never requires lib/Diorama) + T.eq(V3.cull, nil, "nothing has left a viewport on Voxel3D") + T.eq(V3.keyColor, nil, "nor a chroma key") + + -- ------- the grips + D.reset() + local function hand(x, y, z) return { pos = { x, y, z } } end + T.eq(D.gesture({ gripL = 0, gripR = 0 }), false, "an open hand holds nothing") + -- one grip: the model follows that hand, metre for metre, and the first + -- frame of a squeeze moves nothing (there is no delta yet) + D.gesture({ gripL = 1, handl = hand(0, 1, 0) }) + T.eq(D.offset[2], 0, "taking hold does not itself move the model") + D.gesture({ gripL = 1, handl = hand(0.1, 1.25, -0.2) }) + T.check(math.abs(D.offset[1] - 0.1) < 1e-9 + and math.abs(D.offset[2] - 0.25) < 1e-9 + and math.abs(D.offset[3] + 0.2) < 1e-9, + "one grip carries it through the room in all three axes") + D.gesture({ gripL = 0, gripR = 0 }) + D.gesture({ gripL = 1, handl = hand(5, 5, 5) }) + T.check(math.abs(D.offset[2] - 0.25) < 1e-9, + "letting go and taking hold somewhere else never snaps it") + -- both grips: the turn and the resize, off the line between the hands + D.reset() + D.gesture({ gripL = 1, gripR = 1, + handl = hand(-0.2, 1, 0), handr = hand(0.2, 1, 0) }) + local zoom0 = D.zoom + D.gesture({ gripL = 1, gripR = 1, + handl = hand(0, 1, 0.2), handr = hand(0, 1, -0.2) }) + T.check(math.abs(D.yaw - math.pi / 2) < 1e-6, + "hand over hand turns the model by the same angle the mapping yaws by") + T.check(math.abs(D.zoom - zoom0) < 1e-6, + "a pure turn does not resize it") + D.gesture({ gripL = 1, gripR = 1, + handl = hand(0, 1, 0.4), handr = hand(0, 1, -0.4) }) + T.check(math.abs(D.zoom - zoom0 * 2) < 1e-6, + "opening the hands opens the viewport by the same factor") + for _ = 1, 12 do + D.gesture({ gripL = 1, gripR = 1, + handl = hand(0, 1, 4), handr = hand(0, 1, -4) }) + D.gesture({ gripL = 1, gripR = 1, + handl = hand(0, 1, 0.001), handr = hand(0, 1, -0.001) }) + end + T.check(D.zoom >= D.SCALE_MIN and D.zoom <= D.SCALE_MAX, + "and it stays inside its stops however hard it is worked") + + -- ------- and NO base under it + -- + -- The ground was extruded a tile deep once, cut to the viewport's shape + -- and wearing Mt Moon's cave floor down its sides. It was removed at the + -- user's request; the cut ends at the ground plane. Pinned so nothing + -- puts a plinth back under the model by accident. + T.eq(D.drawBase, nil, "the diorama draws no base under the world") + T.eq(D.BASE_DEPTH, nil, "and keeps none of the arithmetic for one") + -- ------- and the curve reaches the eyes + -- + -- It did not: eyeCamera hardcoded `curve = 0`, and Voxel3D reads that + -- field with `or` -- where 0 is TRUE in Lua -- so the bend was pinned + -- off for every VR frame and the diorama's own V-CURVE throw did + -- nothing at all. The rig takes it as a parameter now, and declining is + -- still the default for the modes that want to decline. + local POSE = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } } + local FOV = { angleLeft = -0.7, angleRight = 0.7, + angleUp = 0.6, angleDown = -0.6 } + local plain = Rig.eyeCamera(POSE, FOV, { 0, 0, 0 }, { 0, 0, 0 }, 64) + T.eq(plain.curve, 0, + "an eye declines the curve unless it asks -- first person and the " + .. "battle seat both do") + local bent = Rig.eyeCamera(POSE, FOV, { 0, 0, 0 }, { 0, 0, 0 }, 64, nil, + 0.004) + T.eq(bent.curve, 0.004, + "and a diorama's eye carries the bend it asked for, which is what " + .. "the left stick's throw is for") + + -- ------- the mapping carries all three axes now + local a = Rig.dioramaAnchor(0, { 0.5, 0.25, -0.75 }) + T.check(math.abs(a[1] - 0.5) < 1e-9 and math.abs(a[3] + 0.75) < 1e-9, + "the diorama anchor takes the carry in all three axes") + T.check(math.abs(a[2] - (0.25 - Rig.VIEW_DIST)) < 1e-9, + "with the height still riding the rung's own viewing line") + local b = Rig.dioramaAnchor(0, 0.25) + T.check(math.abs(b[2] - a[2]) < 1e-9 and b[1] == 0, + "and a bare number is still the height alone -- STANDARD's own drag") + + -- ------- what the mode refuses + T.eq(VRM.DIORAMA_RUNG, 3, + "a diorama holds the VOXEL ladder on an orbit rung: there is no 2D " + .. "diorama, and no first-person one either") + T.eq(type(VRM.toggleCurve), "function", + "and the left stick's click throws V-CURVE instead of stepping views") + + D.reset() + VRM.setting:sync(false) + T.eq(D.on, false, "and the frame shuts with the session") +end)() + -- ------- the skybox's checker and glow are the sky's own, not the screen's -- -- The bands were already read by angle, but the DITHER between them kept