mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:50:50 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87a6c03017 | |||
| f0d5ca570a | |||
| 063ce6e328 | |||
| c9c5a8901a | |||
| d86d387243 | |||
| 89fe722a62 | |||
| 180dd393ea | |||
| 67bbb3d44b | |||
| 4ee15c4f32 | |||
| 301bd4c1f9 | |||
| 55bc993ed7 | |||
| 6080531b08 | |||
| 53fff766a4 | |||
| 00f2b0bb9a | |||
| c8555e6820 | |||
| 91d9a37e8f | |||
| 0d0ec51f51 | |||
| b50bbe0782 | |||
| 9f74ea7e73 | |||
| 442e9d26d5 | |||
| 6240b50cec | |||
| e14cf3de90 | |||
| 77e0f93315 | |||
| 20c9061625 | |||
| dde0879527 | |||
| 9e54656fe3 | |||
| c6b38f8d44 |
@@ -44,3 +44,4 @@ model_extract/viewer.html
|
||||
# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with
|
||||
# tools/stadium_pack.py whenever it is wanted
|
||||
assets/stadium/
|
||||
pocket-voxel/
|
||||
@@ -4,6 +4,8 @@
|
||||
# The SDK suite and the probes it grew out of. A shipped test that requires
|
||||
# an engine module reads as a private require against the archive
|
||||
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
|
||||
tests/arena_config.lua
|
||||
tests/arena_editor.lua
|
||||
tests/arena_pick.lua
|
||||
tests/battle_shots.lua
|
||||
tests/dramatic_shape_test.lua
|
||||
|
||||
+190
@@ -1,5 +1,195 @@
|
||||
# Changelog
|
||||
|
||||
## 1.8.0
|
||||
|
||||
### Added
|
||||
|
||||
- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new
|
||||
three-rung row. CATCH ONLY changes nothing about the game except the
|
||||
throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the
|
||||
BALL row of the safari menu) opens capture mode instead of the automatic
|
||||
toss. FULL makes wild encounters the real Let's Go article: the
|
||||
encounter IS the catch -- it opens in throwing mode and stays there,
|
||||
the foe never takes a turn, your own Pokemon is never sent out or
|
||||
shown (no back pic, no model, no HUD), and B runs, which from a catch
|
||||
encounter always works. Poke/Great/Ultra Balls are half price at every
|
||||
mart, and EXPERIENCE works the way that game's does: every healthy
|
||||
party member gains from every catch AND every trainer knockout, each
|
||||
one measured against its OWN level through the Gen VII scaled formula
|
||||
-- which is why Let's Go ships no EXP.ALL, and why a level 5 party
|
||||
member takes several times what a level 45 one does from the very same
|
||||
fight. A catch adds the throw stack on top: grade, first ball of the
|
||||
encounter, new species, and a persistent catch combo. CATCH ONLY
|
||||
leaves experience exactly as the original game had it.
|
||||
|
||||
**The throw.** The camera locks HEAD ON with the wild Pokemon -- its
|
||||
own seat on the arena's axis, no drift, no steer -- and a real 3D Poke
|
||||
Ball (modelled and animated for this: hinged lid, capture beam,
|
||||
squash-click, decaying wobble, caught stars, breakout burst; GREAT
|
||||
blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the
|
||||
bottom of the frame. The ball rides UNDER the finger -- mouse, touch,
|
||||
or the right stick -- and releasing throws it with the swipe's own
|
||||
velocity: forward from how hard, height from its rise, side from its
|
||||
slant, gravity and collision deciding the rest, with a bearing-and-
|
||||
range assist trimming honest errors. Circling the ball WINDS it -- the
|
||||
spin visibly builds with the gesture to a cap and bleeds off when the
|
||||
hand pauses -- and only a ball at the cap flies with the late-biting
|
||||
curve. Contact is against the creature's own GEOMETRY: a pic foe is
|
||||
its sprite's opaque pixels (a ball through the gap under a wing flies
|
||||
on), a STADIUM foe its model's measured height, girth and hover. The
|
||||
timing ring pulses on the creature, coloured by the Gen 1 odds, and is
|
||||
judged AT the moment of contact: inside earns NICE / GREAT /
|
||||
EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
|
||||
shakes the roll answers are the rocks the ball plays on the ground.
|
||||
|
||||
**What it stands on.** The outcome is exactly a Gen 1 ball throw: same
|
||||
catch math (status, HP and ball factors intact), same outcome texts,
|
||||
same caught flow -- dex page, nickname, box overflow -- and a missed
|
||||
ball is a spent ball. Outside FULL, a failed throw still costs the
|
||||
turn it always did. Needs the staged 3D battle standing (3D-BTL on, a
|
||||
depth-capable driver, no headset); anywhere it cannot stand, balls
|
||||
quietly take the engine's classic toss.
|
||||
|
||||
## 1.7.1
|
||||
|
||||
### Added
|
||||
|
||||
- **RENDER DIST: stop drawing the map you cannot see.** The orbit rungs now
|
||||
cut the world to the ground the camera actually frames, so a connected
|
||||
map that falls entirely outside it is skipped before it is drawn --
|
||||
terrain, water, grass, flowers and its whole shadow pass. At the high
|
||||
rungs, where the camera is nearly overhead, that is most of the frame's
|
||||
geometry never submitted.
|
||||
|
||||
**The footprint is not the window.** Tilt the camera and the ground it
|
||||
frames stops being the flat game's own rectangle and becomes a
|
||||
trapezoid: reaching much further north, flaring much wider out there,
|
||||
and pulling in at the near edge. At 35 degrees a 320x288 view reaches
|
||||
270 world pixels north where the window reaches 144, and 246 to each
|
||||
side where the window reaches 160 -- so a window-sized cut takes a bite
|
||||
out of a world plainly on screen, with sky showing through the top and
|
||||
both sides. It is derived from the orbit's own basis rather than guessed
|
||||
-- the frame's corner rays dropped on the ground plane, in closed form,
|
||||
cross-checked against a ray cast in the suite -- and the stored
|
||||
rectangle sits north of the view centre, because the trapezoid does.
|
||||
|
||||
**The row is a real render distance at 75.** Past about 63 degrees
|
||||
(exactly `atan(2*FOCAL)`) the horizon is inside the frame and "all the
|
||||
ground on screen" is an infinite answer, so something has to name a
|
||||
distance. FIT is the closest of the four, WIDE through WIDEST push the
|
||||
world's edge out, OFF stops cutting. Below that pitch the honest
|
||||
footprint is already inside the reach and the row does nothing to the
|
||||
picture at all.
|
||||
|
||||
The cut reaches the shader as the same box the headset's DIORAMA uses --
|
||||
rectangular now, with two half-extents, and the diorama passes the same
|
||||
number twice. Under V-CURVE the rim dissolves rather than cutting,
|
||||
because a bent world has no straight sides. The sun and the eye ask the
|
||||
same question about the same maps, so the light can never record a map
|
||||
the camera did not draw.
|
||||
|
||||
Not on 1ST or 3RD -- the player is standing in the world there -- and
|
||||
the box opens out and away over the rung tween rather than vanishing on
|
||||
the frame the rung changed. FULL sets it to FIT.
|
||||
|
||||
## 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
|
||||
|
||||
- **The air of Viridian Forest: volumetric god rays, ground fog, and a
|
||||
FOREST FX row.** An invisible jungle canopy now hangs above the forest's
|
||||
real trees, and light comes down through it as true volumetric beams: a
|
||||
per-pixel march reads the frame's own depth buffer and the sun's own
|
||||
shadow map, so shafts stand exactly where light really breaks between
|
||||
the tree hulls, trunks and passing characters carve dark columns
|
||||
through them, and a wind-blown leaf field at the canopy plane opens and
|
||||
closes the beams like foliage moving overhead. The beams are alpha zero
|
||||
at the canopy and fade in as they descend -- light below the leaves,
|
||||
never a lid above them -- and a forward-scattering term blooms them for
|
||||
a camera looking up into the light, first person especially.
|
||||
|
||||
The scene shader gains a height-and-distance fog every surface sinks
|
||||
into, and the rays are that fog lit: one shared ramp off the day/night
|
||||
clock colours both, gold spears of sun by day, silver moon rays after
|
||||
dark, dying back through the twilights as one hands over to the other.
|
||||
Pollen drifts through the day's beams and fireflies blink low over the
|
||||
floor at night, all shader-animated and deterministic. A fight staged
|
||||
on the forest floor sits in the same haze at half density.
|
||||
|
||||
The direction never moves: a canopy map's light is pinned to noon (see
|
||||
DayNight.CANOPY), so the beams always agree with the shadows on the
|
||||
floor. Everything is authored per map in `data/map_atmosphere.lua` --
|
||||
a map with no entry spends nothing -- and the **FOREST FX** row (FULL /
|
||||
LOW / OFF, FULL by default) governs the cost: LOW halves the march and
|
||||
stands the particles down. On Android the row offers LOW / OFF only --
|
||||
no mobile driver grants the readable depth the march needs -- so the
|
||||
forest keeps its haze there and loses the beams.
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Exeggutor, Tangela and Magmar stand as models in STADIUM battles, and
|
||||
Pidgeot and Dodrio stop animating garbled.** Five species animate with
|
||||
hermite keyframes rather than packed per-frame streams, and the extractor
|
||||
read the animation flags byte from the wrong half of its u16 -- the half
|
||||
that is always zero -- so it decoded their keyframe tables as streams.
|
||||
For Exeggutor, Tangela and Magmar the result exploded so hard the packer
|
||||
declined them to flat battle pics; Pidgeot and Dodrio stayed models but
|
||||
played the garbage. All five now decode the way the game's own sampler
|
||||
(src/17300.c) does, and no species is held off the field any more.
|
||||
|
||||
Model packs built by an older version of the mod are detected by a
|
||||
revision stamp in the install marker and rebuilt from the ROM on the next
|
||||
launch (or shadowed by a checkout's freshly packed set) rather than
|
||||
trusted.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DramaticShape
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dramatic Shape Voxel Mod
|
||||
|
||||
Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission.
|
||||
|
||||
A mod for the [Pokémon Gen 1 Recompilation
|
||||
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
||||
|
||||
@@ -17,7 +19,8 @@ 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 |
|
||||
| the **RENDER DIST** options row | FIT / WIDE / WIDER / WIDEST / OFF — how much of the map the camera bothers to draw. **FIT** is exactly the ground on screen and no more: the trapezoid a tilted camera really frames, which reaches well north of you and flares wide out there — not the square the flat game shows. A connected map falling entirely outside it is skipped before it is drawn, terrain, water, grass and shadows together, which is most of the frame's geometry at the high rungs. Below about 63° that is all the row does and the picture is untouched; at **75** the camera sees to the horizon, so something has to name a distance — FIT is the closest, the wider rungs push the world's edge out, **OFF** stops cutting. Not on **1ST** or **3RD**: you are standing in the world there, and the box opens out and away as the camera dives in. **FULL** sets it to FIT |
|
||||
| `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 +188,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,17 +237,16 @@ 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 |
|
||||
|
||||
## Licenses
|
||||
|
||||
This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
|
||||
|
||||
It redistributes one third-party binary:
|
||||
|
||||
- **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader
|
||||
@@ -263,4 +295,4 @@ from upstream and follow that project's own terms.
|
||||
|
||||
No Pokémon Stadium ROM data ships here either. The models are built on the
|
||||
player's own machine, from a cartridge they supply, into their own save
|
||||
directory — see [Getting the models](#getting-the-models).
|
||||
directory — see [Getting the models](#getting-the-models).
|
||||
|
||||
@@ -61,6 +61,10 @@ cues generalize:
|
||||
| Band containing window/door frames | Vertical facade | Straight extrusion |
|
||||
| Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion |
|
||||
| Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning |
|
||||
| Scattered light shapes on a dark field, bracketed by TWO full-width black rims, shallow band below the lower rim | The **inside of an open container** seen from above, with contents lying in it | Hollow tray: walls to the rims, floor slab, air between — never an extrusion |
|
||||
| Ellipse drawn wider than tall (e.g. 9x5) | A horizontal circle seen from above — a mouth, a lid, a pot rim | Cut face of a round hull; the aspect ratio is the proof of the top view |
|
||||
| Arcs above/below a round object's straight flanks, lowest point at the centre column, often a 1px #555 halo outside | The SAME circles seen curving — ground contact and mouth back-edge, i.e. depth, not narrowing | Strip them from the revolve; run the last body row's disc to the floor |
|
||||
| A side band shearing sideways as it descends (¾-view) | The projection sliding a receding wall, not the wall's position | Un-project: the wall goes where the plan says |
|
||||
|
||||
The band table for Red's house, which Blue's house shares verbatim:
|
||||
|
||||
@@ -156,9 +160,12 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews).
|
||||
|
||||
1. Obtain the sprite; sample to native resolution via block centers.
|
||||
2. Extract palette + silhouette (light-only flood fill, threshold 130);
|
||||
review the ASCII mask.
|
||||
3. Segment rows into bands using the Stage-2 cues; write the band table
|
||||
before writing any geometry code.
|
||||
review the ASCII mask — rendered large, not hand-counted.
|
||||
3. Name the real object first (including whether it is hollow, round or
|
||||
thin — see "Beyond the house"), then segment rows into bands using the
|
||||
Stage-2 cues; write the band table as prose, one line per row range
|
||||
with where each band lands, before writing any geometry code. The
|
||||
correct reading makes the row arithmetic land exactly.
|
||||
4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`.
|
||||
5. Build: extrude verticals (de-outlined interiors) → ledges → recesses →
|
||||
flat top (mid-row cycling) → sloped solids (overwrite, then trim) →
|
||||
@@ -214,3 +221,75 @@ right for the raw GB palette but comes out white once the atlas is
|
||||
recoloured, turning every sloped end into a black-and-white zip. The
|
||||
drawing's own eave is black / `#555` / black, and using that reads correctly
|
||||
under every palette.
|
||||
|
||||
## Beyond the house: the forms later objects added
|
||||
|
||||
The house is all solid masses — every band either lies flat or extrudes.
|
||||
Later objects forced the taxonomy open, and each addition came from the
|
||||
same root move: **name the real 3D form first, then ask which surfaces the
|
||||
drawing shows.** The recurring failure at every step was the *extruded
|
||||
picture* — and it has a second-order form that survives re-segmentation.
|
||||
The Bike Shop's toolbox was re-read from "a prop" into "a cabinet with a
|
||||
pump beside it": named parts, correct plot, de-outlined sides, and still
|
||||
wrong, because the region read as a cabinet *front* was the inside of an
|
||||
open box seen from above. Naming the parts is not enough; every REGION
|
||||
must answer "what surface of the real object is this?" The reliable
|
||||
arbiter is arithmetic: the correct reading makes the drawn row counts land
|
||||
exactly (the toolbox: 1 back-wall rim + 6 interior rows + 1 front rim = 8
|
||||
= the one-tile plot depth). Forcing rows to fit means the reading is wrong.
|
||||
|
||||
**Hollow forms.** An open container is the one shape whose model must
|
||||
contain AIR, which no band table or extrusion can produce. The tray
|
||||
treatment builds four walls to the drawn rims, lays the top-view band on
|
||||
the floor of the cavity (its contents — a wrench — come along free, since
|
||||
they are just pixels of that band), and leaves the space between empty.
|
||||
Two rules only containers hit: the pane-recess pass must never run on a
|
||||
one-voxel wall (it deletes the front voxel to expose the one behind, and
|
||||
there is nothing behind — the wall becomes a hole), and the hollowness
|
||||
needs its own verification assert, because a later change that refills the
|
||||
cavity leaves every count looking plausible.
|
||||
|
||||
**Round forms.** A drawn ellipse wider than tall is a horizontal circle
|
||||
seen from above — that one aspect-ratio measurement settles the whole
|
||||
reading. Straight flanks give diameter and height at once (round in plan,
|
||||
so drawn width IS depth — the one depth never authored). The arcs above
|
||||
and below the straight run are the same top and base circles seen curving:
|
||||
ground contact and mouth edge, not narrowing — revolving them puts the
|
||||
object on a stem. The hull's chord representation stores one z-interval
|
||||
per column/row, so a taper is expressible (re-cut the chords, squeeze the
|
||||
art into the narrowed span so the rim outline survives) but a hollow ring
|
||||
needs a second chord. Voxel resolution bounds taste: on an 11-wide object
|
||||
a one-step taper reads as damage and two steps as a cone; pick the step
|
||||
count and derive the amount.
|
||||
|
||||
**Thin forms.** A line drawing cannot be thick. The air inside a bicycle's
|
||||
frame is what makes it read as a bicycle; extrude each stroke 5 voxels and
|
||||
the side faces of neighbouring strokes close every gap off-axis — six
|
||||
bikes become one dark mass. Standee thickness is a vocabulary
|
||||
(`PINNED_DEPTH`: 1 for paper, 2 for plates and side-on vehicles, 5 for
|
||||
silhouettes, 10 for objects with a body), and when a standee looks wrong
|
||||
the first move is to dump the detector's mask — if the mask is a clean
|
||||
object, thickness is the problem, not segmentation.
|
||||
|
||||
**Authored masks.** When a drawing shares its tiles and shades with what
|
||||
it is painted into, nothing automatic can separate them; the profile
|
||||
carries a pixel mask instead. A person becomes a `figures` card (flat,
|
||||
leaning with the camera, standing on its feet — because GB character art
|
||||
is face-on iconography); an object becomes a `mounted` slab (fixed in the
|
||||
world, holding the wall's plane, keeping its drawn elevation — because a
|
||||
side-on drawing is a plane parallel to the wall). And when the backdrop is
|
||||
a *regular* pattern, the mask should be MEASURED, not hand-drawn:
|
||||
composite the plain backdrop tile over the same grid and flood from the
|
||||
border through pixels that still match it — what the flood cannot reach is
|
||||
the object, sprite-pure and exact.
|
||||
|
||||
**Verification, extended.** Isometric previews miss what only the game
|
||||
shows: shoot in-game at both the ¾ rung and the low rung (front-face holes
|
||||
and proportion errors are invisible from above), crop and NEAREST-upscale
|
||||
before judging, and remember the flat rung renders no model at all. Two
|
||||
cheap renders beat argument: the front-most voxel per (x, y) laid beside
|
||||
the composited drawing catches anchoring and texel leaks instantly, and
|
||||
the same render with sunk voxels flagged turns the recess pass into
|
||||
something you look at. When shared builder code moves, a saved count
|
||||
baseline diffed after every edit (mind the line endings) is what proves a
|
||||
generalization is an identity for every model that already shipped.
|
||||
|
||||
+88
-64
@@ -23,6 +23,20 @@
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/arenas \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
|
||||
--
|
||||
-- To change ONE map's spot, or to go over these by eye, run the editor
|
||||
-- instead -- it is the same choice made in front of the map rather than in a
|
||||
-- batch. It slides the arena around on a plan of the map with the fit, the
|
||||
-- clearance and the camera's own sightlines answering live, stages a real
|
||||
-- battle on the spot when asked, and writes this file back a map at a time,
|
||||
-- replacing only the lines that changed and leaving every comment here alone:
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
|
||||
--
|
||||
-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
|
||||
-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
|
||||
-- moved describes where that spot USED to be, and it says so by name at
|
||||
-- export time -- those are the lines to re-word by hand.
|
||||
|
||||
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
|
||||
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
|
||||
@@ -49,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
|
||||
@@ -64,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
|
||||
--
|
||||
@@ -78,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
|
||||
@@ -89,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
|
||||
@@ -163,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
|
||||
@@ -190,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" },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- What hangs in the air of each map.
|
||||
--
|
||||
-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader
|
||||
-- folds every surface into, and volumetric god rays -- light let down
|
||||
-- through an INVISIBLE canopy hanging above the map's real geometry, as
|
||||
-- if the trees drawn are only the understorey of something taller. The
|
||||
-- rays are not placed: a per-pixel march (see ForestAtmos) reads the
|
||||
-- frame's own depth and the sun's own shadow map, so the beams stand
|
||||
-- exactly where light really breaks between the tree hulls, trees and
|
||||
-- characters carve dark columns through them, and a wind-blown leaf
|
||||
-- field at the canopy plane opens and closes them like foliage moving
|
||||
-- overhead. The light leans along the mod's fixed noon shear (the one
|
||||
-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
|
||||
-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
|
||||
-- rays after dark, pollen adrift in the day's beams and fireflies once
|
||||
-- they cool.
|
||||
--
|
||||
-- A map with no entry here has no atmosphere at all: no fog uniform is
|
||||
-- raised, no march runs, nothing is spent. That is the contract a new
|
||||
-- map opts into by adding a line, and what a stale entry degrades to if
|
||||
-- its map id ever stops existing.
|
||||
--
|
||||
-- The knobs, in world pixels unless said otherwise (a map cell is 16):
|
||||
--
|
||||
-- canopyY where the invisible canopy hangs. MUST clear the tallest
|
||||
-- real geometry under it (Viridian's carved tree hulls top
|
||||
-- at y = 32) -- a beam is alpha ZERO at this height and only
|
||||
-- fades in below it, so a canopy at or under the tree tops
|
||||
-- would cut every ray off before it cleared the leaves.
|
||||
-- fadeTo the height by which a descending ray reaches full strength.
|
||||
-- fog density how fast distance dissolves into the haze
|
||||
-- (1 - exp(-density * distance-past-start))
|
||||
-- start how many pixels out the dissolve begins
|
||||
-- heightK how quickly the fog thins with ALTITUDE
|
||||
-- (exp(-y * heightK): 0.02 halves it by y = 35)
|
||||
-- rays strength overall in-scatter gain on the march
|
||||
-- reach how far out the march walks, in world px
|
||||
-- motes count of pollen/dust flecks adrift in the daylight beams
|
||||
-- fireflies count of the night shift
|
||||
-- seed the xorshift seed the particle deal runs on
|
||||
--
|
||||
-- Two caveats for maps opting in later: the water pass has no fog term,
|
||||
-- so a lake under heavy haze stays clear-day sharp in its reflections;
|
||||
-- and the march runs after the water's mirror copy, so beams will not
|
||||
-- appear IN those reflections either. Neither can bite in a map without
|
||||
-- water.
|
||||
|
||||
return {
|
||||
["VIRIDIAN_FOREST"] = {
|
||||
canopyY = 56,
|
||||
fadeTo = 28,
|
||||
fog = { density = 0.0045, start = 64, heightK = 0.02 },
|
||||
-- strength is calibrated against the march's real integral: the
|
||||
-- under-canopy stretch of an orbit ray is short and thinly dense, so
|
||||
-- the raw accumulation for a fully lit beam core is a few percent --
|
||||
-- this gain lands it near +0.3 on screen. Halve it for a whisper,
|
||||
-- double it for cathedral light.
|
||||
rays = { strength = 16, reach = 380 },
|
||||
motes = { count = 96 },
|
||||
fireflies = { count = 48 },
|
||||
seed = 0x51D,
|
||||
},
|
||||
}
|
||||
+293
-21
@@ -168,6 +168,10 @@ return {
|
||||
-- a round drawing stacked two cells high on one cell of plot (the
|
||||
-- Centers' potted plants): 32px of hull standing in its lower cell
|
||||
planter = 32,
|
||||
-- the little trees (Celadon Gym's garden, the overworld's cuttable
|
||||
-- tree): the round hull squashed front to back, 16px of drawn
|
||||
-- elevation like every other one-cell tree drawing
|
||||
sapling = 16,
|
||||
relief = 3,
|
||||
bookcase = 32,
|
||||
stair_e = 16,
|
||||
@@ -197,7 +201,27 @@ return {
|
||||
-- cutouts both read wrong for them; the cylinder archetype carves
|
||||
-- one voxel ball per 16x16 cell from the canopy's darkest-pixel
|
||||
-- outline, round in depth, so tree rows become rows of real canopies
|
||||
--
|
||||
-- The cuttable tree ($2D/$2E/$3D/$3E, the four tiles Cut deletes)
|
||||
-- takes the same hull SQUASHED: it is Celadon Gym's little tree
|
||||
-- redrawn pixel-for-pixel on this atlas (same 146/256 silhouette,
|
||||
-- only canopy highlight texels differ), so the two must stay one
|
||||
-- model -- the scraggly canopy revolves into a gapped ball, the
|
||||
-- 2-4px trunk into a thin round column, the root flare into a
|
||||
-- round mound. It used to sit in the `prop` pool as a 5-voxel
|
||||
-- standee; see GYM's sapling entry for the reading and for what
|
||||
-- sapling_squash does. Scanned: the 2x2 grid
|
||||
-- occurs 32 times on this atlas across the towns and routes, and
|
||||
-- per-tile counts equal the grid count (32 hits for $2D alone),
|
||||
-- so no stray occurrence renders as a lone hull. The same four
|
||||
-- ids form grids on FOREST (8, Safari Zone ground art), HOUSE,
|
||||
-- MANSION, SHIP_PORT and REDS_HOUSE_2 -- different drawings, id
|
||||
-- collisions, none of this entry's business. Cut itself only
|
||||
-- swaps the map block to plain grass, so the pin never sees a
|
||||
-- cut stump.
|
||||
cylinder = { 42, 43, 58, 59, 64, 65, 80, 81 },
|
||||
sapling = { 45, 46, 61, 62 },
|
||||
sapling_squash = 50,
|
||||
-- the town sign (blockset 8's SE cell): a standing per-pixel slab
|
||||
-- 2 voxels thin, transparency respected -- never a solid box
|
||||
signpost = { 70, 71, 86, 87 },
|
||||
@@ -220,15 +244,11 @@ return {
|
||||
-- instead -- see there.)
|
||||
wall = { 2, 36 },
|
||||
|
||||
-- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes
|
||||
-- -- across the whole tileset they appear only in the five
|
||||
-- cut-tree blocks): a standing per-pixel cutout 5 voxels deep,
|
||||
-- black-outline segmented with the pixels the outline encloses
|
||||
-- kept, its drawn grass dither flooding away as background
|
||||
prop = { 45, 46, 61, 62 },
|
||||
-- the ground painted under those pinned props, by the prop tile's
|
||||
-- own id: the bush stands on plain grass ($2C) -- the very tile
|
||||
-- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather
|
||||
-- (the cuttable tree $2D/$2E/$3D/$3E moved to the cylinder hull
|
||||
-- above)
|
||||
-- the ground painted under the claimed tree cells, by tile id:
|
||||
-- the tree stands on plain grass ($2C) -- the very tile Cut
|
||||
-- leaves behind (field.cutTreeSwaps' after-blocks) -- rather
|
||||
-- than whatever flat tile its neighbours vote
|
||||
prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 },
|
||||
},
|
||||
@@ -345,6 +365,38 @@ return {
|
||||
-- the pin is not copied there.
|
||||
cylinder = { 44, 45, 46, 47,
|
||||
7, 8, 23, 24 },
|
||||
-- Celadon's three little trees ($40/$41 canopy over $50/$51
|
||||
-- trunk): a scraggly canopy over a 2-4px trunk flaring into a
|
||||
-- round root mound. Every drawn row states its own width, which
|
||||
-- is exactly what the hull revolves -- the canopy turns into a
|
||||
-- ball with its drawn gaps kept, the trunk into a thin round
|
||||
-- column, the mound into a round foot. They used to sit in the
|
||||
-- `prop` pool ("a trunk is not round"), but the standee rendered
|
||||
-- as the whole 16x16 cell extruded, background and all -- the
|
||||
-- extruded picture -- and the trunk IS round; the hull reads it
|
||||
-- right. The same drawing is the overworld's cuttable tree (see
|
||||
-- OVERWORLD's sapling entry); one drawing, one model.
|
||||
--
|
||||
-- sapling_squash 50 is the one AUTHORED number and the only knob
|
||||
-- taste moves: the percent of its revolved depth every chord
|
||||
-- keeps. A full revolve (100) assumes the drawing's width is
|
||||
-- also its depth, which is honest for the hedge balls and
|
||||
-- boulders in the `cylinder` pool above but not for a tree --
|
||||
-- the trunk is a stick, the crown is more air than wood, and at
|
||||
-- full width the tree filled a whole cell of depth and read as a
|
||||
-- boulder wearing bark. 50 halves it to an ellipse in plan;
|
||||
-- the model stays round in section and centred on the cell.
|
||||
-- The height is NOT authored: the class's 16 (see the `heights`
|
||||
-- table at the top of this file) is the drawn elevation, which
|
||||
-- is also the model's top plane, so anything riding a tree cell
|
||||
-- lands right.
|
||||
-- Scanned: the 2x2 grid occurs 3 times on this atlas and only
|
||||
-- in CELADON_GYM -- cells (2,4), (7,5), (5,7) -- and per-tile
|
||||
-- counts equal the grid count (3 hits for $40 alone), so no
|
||||
-- stray occurrence renders as a lone hull. DOJO shares gym.png
|
||||
-- and places none, so the pin is not copied there.
|
||||
sapling = { 64, 65, 80, 81 },
|
||||
sapling_squash = 50,
|
||||
-- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch
|
||||
-- puzzle's fifteen cans plus the sixteenth beside the leader's
|
||||
-- platform. An open galvanised bin in the 3/4 view, and its plan is
|
||||
@@ -405,13 +457,9 @@ return {
|
||||
can_well = 5,
|
||||
can_taper = 4,
|
||||
heights = { can = 9 },
|
||||
-- The statues and Celadon's three little trees ($40/$41 canopy over
|
||||
-- $50/$51 trunk). A trunk is not round, so the tree cannot be a
|
||||
-- ball like the shrubs beside it -- it takes the thin standee pool
|
||||
-- every interior plant takes, which is also a pool apart from the
|
||||
-- cylinders it touches.
|
||||
prop = { 2, 56, 18, 19,
|
||||
64, 65, 80, 81 },
|
||||
-- The statues. (Celadon's trees $40/$41/$50/$51 lived here
|
||||
-- too until they moved to the sapling hull above.)
|
||||
prop = { 2, 56, 18, 19 },
|
||||
-- The Hall of Fame's recording machine, the one piece of real
|
||||
-- furniture in the tileset. It is drawn 32px wide and THREE tile
|
||||
-- rows tall against the north band, and the detector made a mess
|
||||
@@ -685,7 +733,15 @@ return {
|
||||
-- per-cell hulls rather than boxes
|
||||
canopy = { 4 },
|
||||
cylinder = { 5, 6, 7, 21, 22, 23,
|
||||
35, 36, 37, 38, 39, 53, 54 },
|
||||
35, 36, 37, 38, 39, 53, 54,
|
||||
-- the Safari Zone's small round trees ($54/$55/$56/$57,
|
||||
-- one cell, 376 placements across the four safari maps
|
||||
-- and nowhere else on this tileset): drawn as a canopy
|
||||
-- ball like the overworld's lone tree, and the detector
|
||||
-- was boxing them into 16px dither-textured crates.
|
||||
-- One voxel ball per cell, the same hull the big trees'
|
||||
-- quarter tiles degrade to
|
||||
84, 85, 86, 87 },
|
||||
-- the stumps ($02/$03/$12/$13): a hull whose drawn top is a CUT
|
||||
-- FACE. The body builds from the bark rows alone, and the drawn
|
||||
-- ellipse of growth rings projects onto the hull's round flat
|
||||
@@ -1488,9 +1544,14 @@ return {
|
||||
-- terrace: their north rim (9/25 = $09/$19) and the
|
||||
-- pedestal course at the south (85/86/87).
|
||||
--
|
||||
-- THE ROUND TABLES in full, because the shape is a compromise. The
|
||||
-- drawing (block 29, and the same four rows split across blocks 45
|
||||
-- and 49 in the diner) is
|
||||
-- THE ROUND TABLES in full, because the shape is a compromise.
|
||||
-- (The `diner_round_table` template under `buildings` below now
|
||||
-- models all four placements in full -- octagonal top on its
|
||||
-- pedestal -- by matching the whole 4x4 grid, which is what a
|
||||
-- per-tile pin can never do. Everything here stays as its
|
||||
-- degradation path and as the record of why the pins look the
|
||||
-- way they do.) The drawing (block 29, and the same four rows
|
||||
-- split across blocks 45 and 49 in the diner) is
|
||||
-- $09 $27 $27 $19 an octagonal top seen from above, with
|
||||
-- $36 $37 $37 $39 a pedestal drawn below its southern
|
||||
-- $46 $37 $37 $47 rim
|
||||
@@ -1511,7 +1572,7 @@ return {
|
||||
-- 8px is the FAR rim (9/25, and the shared 39/54/57) and the
|
||||
-- pedestal (85/86/87): the far rim is occluded by the 16px top in
|
||||
-- front of it, and the pedestal is meant to sit low. 16px is also
|
||||
-- the right height against the 8px `stool` chairs drawn around it
|
||||
-- the right height against the seat-high `stool` chairs around it
|
||||
-- -- a terrace table you sit at, not a footstool.
|
||||
counter = { 9, 21, 25, 36, 37, 38, 39, 41, 48, 49, 52, 53, 54,
|
||||
57, 85, 86, 87 },
|
||||
@@ -1541,7 +1602,15 @@ return {
|
||||
-- drawing carries a full black outline with the floor dither
|
||||
-- showing at all four corners, so the standee segments cleanly --
|
||||
-- and `stool` keeps its own pool, apart from anything it touches.
|
||||
-- The `diner_stool` template (see `buildings` below) now models
|
||||
-- every placement in full, like the house stool it copies; these
|
||||
-- pins are its degradation path.
|
||||
stool = { 7, 8, 23, 24 },
|
||||
-- the `diner_stool` template stands 5 voxels (the drawn
|
||||
-- elevation: the seat's front edge at row 10 over legs 11-14),
|
||||
-- as the house stool does: whoever sits on a stool cell rides
|
||||
-- this height, not the 8px class default
|
||||
heights = { stool = 5 },
|
||||
-- Deliberately NOT pinned:
|
||||
-- $37 (55) is three different things -- the light half of the
|
||||
-- checkerboard floor, the interior of the round tables, and
|
||||
@@ -1827,6 +1896,18 @@ return {
|
||||
-- 18/19) sitting in a WALKABLE cell, so flat floor until pinned.
|
||||
-- The 8px standee pool, seat height, as in Red's rooms.
|
||||
stool = { 2, 3, 18, 19 },
|
||||
-- ...and the height a figure riding that cell stands at, which is
|
||||
-- the SEAT and not the backrest: the chair's seat is drawn rows
|
||||
-- 26-31, six of them, so 6. All three Game Freak developers are
|
||||
-- placed ON their chair cell (CELADON_MANSION_3F objects at (0,4),
|
||||
-- (3,4) and (0,7)), and VoxelScene.groundAt reads this pin, not
|
||||
-- the `mansion_computer_desk` model that now draws the chair -- at
|
||||
-- the class default 8 they floated two voxels over the seat. The
|
||||
-- same reading INTERIOR's `stool = 5` carries for Bill's chair,
|
||||
-- which is this drawing with one white margin column instead of
|
||||
-- two; these four ids are the mansion desks' chairs and nothing
|
||||
-- else on this atlas, so the override reaches only them.
|
||||
heights = { stool = 6 },
|
||||
-- the potted palms: two cells of drawing (68/69 crown, 8/9 fronds,
|
||||
-- 70/71 stem, 24/25 pot), mostly silhouette, so the THIN standee
|
||||
-- pool -- the same numbers the generic HOUSE entry uses, and the
|
||||
@@ -4169,6 +4250,109 @@ return {
|
||||
roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
|
||||
slab = 3, frontEave = 0, ledge = nil,
|
||||
},
|
||||
-- F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7)
|
||||
-- (1 placement, scan.lua) -- the long table (F07) at two cells
|
||||
-- wide, the same drawing to the tile everywhere but the interior
|
||||
-- column count, and the same read to the row: 0-23 the tabletop
|
||||
-- seen from above, 24-26 the slab's black/#555/black front edge,
|
||||
-- 27 the #555 shadow that closes it (slab = 3, folded into the
|
||||
-- band), 28-30 the base with the legs stopping one row short of
|
||||
-- the grid. Family numbers unchanged; the `table` pin stays as
|
||||
-- the degradation path, neutralized where this stamps.
|
||||
{
|
||||
id = "mansion_square_table",
|
||||
tiles = {
|
||||
{ 38, 39, 39, 41 },
|
||||
{ 54, 55, 55, 57 },
|
||||
{ 54, 55, 55, 57 },
|
||||
{ 60, 58, 58, 59 },
|
||||
},
|
||||
roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
|
||||
slab = 3, frontEave = 0, ledge = nil,
|
||||
},
|
||||
-- F07c: the Game Freak office's COMPUTER DESK -- the writing desks
|
||||
-- of CELADON_MANSION_2F cell (0,5) and CELADON_MANSION_3F cells
|
||||
-- (0,3), (3,3) and (0,6). scan.lua on the 4x4 grid returns those
|
||||
-- four and nothing else, and each of the four ids that carry the
|
||||
-- apron and chair rows (2/3/85/86, 18/19) occurs on this atlas ONLY
|
||||
-- inside them -- so no stray cell can pick this model up. (The
|
||||
-- same ids are furniture on the HOUSE and FACILITY atlases; those
|
||||
-- are different images and none of this entry's business.)
|
||||
--
|
||||
-- Rows 0-15 are BILL'S DESK, byte for byte. A whole-crop diff of
|
||||
-- these tiles against the interior atlas's 11/12/13/14 over
|
||||
-- 27/28/29/30 comes back empty for all 512 pixels: the same
|
||||
-- tabletop seen from above -- black rim, white highlight course,
|
||||
-- grey field -- with the same terminal, cord and 2:1 isometric
|
||||
-- computer drawn into it. One drawing is one model, so the four
|
||||
-- parts below are `bills_desk`'s verbatim; that entry carries the
|
||||
-- readings (paper is flat, the keyboard's keys ride its top face,
|
||||
-- `plan` = rx makes the computer a cube turned 45 and not a slab).
|
||||
--
|
||||
-- Only the FRONT is redrawn, and it is 6 rows where Bill's is 7:
|
||||
-- 15 the top's own black front edge. This is the row the
|
||||
-- lid replaces, so it opens the fascia exactly the way
|
||||
-- Bill's row 16 does, and the desk stands 7 voxels to
|
||||
-- his 8 -- measured, not chosen.
|
||||
-- 16-17 the #555 edge lip and the black seam under it: the
|
||||
-- desktop's own rim, which is why they are `fascia` --
|
||||
-- that band wraps every side, and a desktop's edge is
|
||||
-- visible from all four.
|
||||
-- 18-21 the base: the left leg (the black/#555/black at
|
||||
-- x0-x2), the open apron between, and the DRAWER
|
||||
-- PEDESTAL at x20-x31 -- two #555 drawer fronts (rows
|
||||
-- 16-17 and 19-20) inside a black frame. Each is a
|
||||
-- non-black region sealed behind its own black outline
|
||||
-- and under 24px, so the measured recess pass sinks it
|
||||
-- one voxel and the frame stays proud: the drawer gaps
|
||||
-- come off the pixels, nothing is authored.
|
||||
-- Row 21 is the ground line and 22 is the measured one. Rows
|
||||
-- 22-23 are the desk's cast SHADOW, dithered into the checker
|
||||
-- floor of the walkable cell in front, so the model builds none of
|
||||
-- them -- they are floor, not furniture.
|
||||
--
|
||||
-- The chair is Bill's chair REDRAWN rather than the same pixels
|
||||
-- (two white margin columns round the backrest panel where his
|
||||
-- has one, and it sits at x4-x15 rather than x2-x13), but the same
|
||||
-- object band for band -- rows 20-21 the backrest top seen from
|
||||
-- above, 22-31 its elevation -- so it takes his part table with x
|
||||
-- and `rise` moved: `rise` is the whole plane back down, because
|
||||
-- the chair stands on the FLOOR and not on the desk.
|
||||
--
|
||||
-- Why the grid runs two tile rows past the desk: the artist drew
|
||||
-- the apron into the WALKABLE cell in front (2/3 + 85/86), and
|
||||
-- 2/3 also carry the chair's back. A template claims whole TILES,
|
||||
-- so reading the apron takes the chair with it -- which is what
|
||||
-- makes the template owe it, exactly as at Bill's. `desk.depth`
|
||||
-- stops the desk box at its own two cells; the chair keeps the
|
||||
-- front one. The `table` pin on the top tiles and the `stool` pin
|
||||
-- on 2/3/18/19 both stay as the degradation path, neutralized
|
||||
-- wherever this stamps.
|
||||
{
|
||||
id = "mansion_computer_desk",
|
||||
tiles = {
|
||||
{ 36, 37, 52, 53 },
|
||||
{ 64, 65, 66, 67 },
|
||||
{ 2, 3, 85, 86 },
|
||||
{ 18, 19, 17, 17 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 4,
|
||||
-- the desk's own plot is its two cells; the grid runs on because
|
||||
-- its apron and the CHAIR share tiles 2/3
|
||||
desk = { fascia = { 15, 17 }, base = { 18, 21 }, depth = 2 },
|
||||
parts = {
|
||||
{ kind = "flat", x = { 2, 15 }, rows = { 3, 7 } }, -- the notes
|
||||
{ kind = "upright", x = { 4, 15 }, top = { 8, 12 },
|
||||
facade = { 12, 13 }, z = 8, depth = 6 }, -- keyboard
|
||||
{ kind = "upright", x = { 16, 19 }, top = { 8, 8 },
|
||||
facade = { 8, 9 }, z = 8, depth = 2 }, -- the cord
|
||||
{ kind = "iso", x = { 19, 30 }, rows = { 1, 13 },
|
||||
plan = 6, z = 9 }, -- the computer
|
||||
{ kind = "upright", x = { 4, 15 }, top = { 20, 21 },
|
||||
facade = { 22, 31 }, rise = -7, z = 22, depth = 10 }, -- chair
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
HOUSE = {
|
||||
@@ -4480,5 +4664,93 @@ return {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
LOBBY = {
|
||||
-- F09 on the lobby atlas: the Celadon department store's stools
|
||||
-- -- the diner's chairs, the roof terrace's, the Game Corner's
|
||||
-- six rows and the four on 1F (58 placements, the scan's only
|
||||
-- matches on this atlas). A DIFFERENT drawing from the house
|
||||
-- stool -- it sits one row HIGHER in the tile (seat top rows 4-9
|
||||
-- over its front edge at 10 and the legs at 11-14, with a clear
|
||||
-- floor row below) and its leg detail differs -- but the same
|
||||
-- object band for band, so it takes the house part table with
|
||||
-- the bands shifted up one row. The measured ground line (15
|
||||
-- here, 16 in the house) shifts with them, so the stand is the
|
||||
-- same 5 voxels, and the tileset's `stool = 5` height override
|
||||
-- keeps whoever sits here ON the seat. The old stool standee
|
||||
-- pins stay as the degradation path.
|
||||
{
|
||||
id = "diner_stool",
|
||||
tiles = {
|
||||
{ 7, 8 },
|
||||
{ 23, 24 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil,
|
||||
panes = false,
|
||||
parts = {
|
||||
{ kind = "upright", x = { 2, 13 }, top = { 4, 9 },
|
||||
facade = { 10, 14 }, z = 3, depth = 11,
|
||||
stretch = true }, -- the stool
|
||||
},
|
||||
},
|
||||
-- F11: the ROUND TABLE of the diner and the roof terrace -- 4
|
||||
-- placements, all on this atlas (CELADON_DINER cells (0,2) and
|
||||
-- (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan
|
||||
-- "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55" matches
|
||||
-- nowhere else). This is the drawing the long `counter` note
|
||||
-- above calls a compromise -- its four interior tiles are $37,
|
||||
-- unpinnable, so the flat treatment let the whole top BE a 16px
|
||||
-- disc. The template matches the exact 4x4 grid instead, which
|
||||
-- is what a per-tile pin can never do, and un-projects the three
|
||||
-- facings: rows 0-23 the OCTAGONAL top seen from above (24
|
||||
-- top-view rows = 24 depth rows, so the plan is the silhouette
|
||||
-- itself, a `plan` slab 32x24), rows 24-25 the slab's #555/black
|
||||
-- fascia folded down its rim, rows 26-31 the PEDESTAL seen under
|
||||
-- the front edge -- two flattened circles, i.e. horizontal discs:
|
||||
-- the base (diameter 16, drawn cols 8-23, side rows 30-31, its
|
||||
-- top wearing the drawn shadow-and-white rows 26-29) and the dark
|
||||
-- column (diameter 6, cols 13-18, rows 26-27 repeating up the
|
||||
-- shaft), both on the drawn centre x 16 / plan centre z 12.
|
||||
-- MEASURED: plan, diameters, centres, slab 3. AUTHORED: tabletop
|
||||
-- plane 8 -- counter height, developer-tuned (the first cut
|
||||
-- stood it at the flat compromise's 16px and it read too tall)
|
||||
-- -- plus base height 2 and the 3-voxel column between. `depth`
|
||||
-- 3 keeps the plot to the drawn plan; the grid's 4th tile row is
|
||||
-- the pedestal's own drawing plus one floor tile ($37 again, at
|
||||
-- the southeast corner), which the claim paints as ground.
|
||||
-- `scrub` repoints the top's interior field -- the four $37
|
||||
-- tiles, ALSO the checkerboard floor's light half, which carry
|
||||
-- the floor's palette in a colorized atlas -- at the same grey
|
||||
-- sourced from the rim's own field, so the whole top wears the
|
||||
-- table's palette (the drawn field there is uniform grey;
|
||||
-- nothing drawn is lost). The old wall/counter pins on the rim
|
||||
-- tiles stay as the degradation path, and `support` carries the
|
||||
-- top plane so anything the standee scan finds on these cells
|
||||
-- rides the tabletop.
|
||||
{
|
||||
id = "diner_round_table",
|
||||
tiles = {
|
||||
{ 9, 39, 39, 25 },
|
||||
{ 54, 55, 55, 57 },
|
||||
{ 70, 55, 55, 71 },
|
||||
{ 85, 86, 87, 55 },
|
||||
},
|
||||
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||
slab = 0, frontEave = 0, ledge = nil, depth = 3,
|
||||
panes = false, support = 8,
|
||||
scrub = { { 8, 8, 23, 23 } },
|
||||
parts = {
|
||||
{ kind = "disc", cx2 = 32, cz2 = 24, r = 8, rise = 0, h = 2,
|
||||
side = { rows = { 30, 31 }, x = { 13, 18 } },
|
||||
cap = { rows = { 26, 29 }, x = { 9, 22 } } }, -- the base
|
||||
{ kind = "disc", cx2 = 32, cz2 = 24, r = 3, rise = 2, h = 3,
|
||||
side = { rows = { 26, 27 }, x = { 14, 17 } } }, -- the column
|
||||
{ kind = "plan", x = { 0, 31 }, rows = { 0, 23 },
|
||||
fascia = { 24, 25 }, fasciaX = { 8, 23 },
|
||||
rise = 5 }, -- the top
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+72
-8
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
|
||||
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
|
||||
}
|
||||
|
||||
-- ------- which way round the fight stands
|
||||
--
|
||||
-- Both shapes above are drawn north-south, with the foe at the top and the
|
||||
-- player below it, and the camera is solved for that: it sits off the
|
||||
-- player's shoulder, low and back down the arena's own axis. `turn` swings
|
||||
-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
|
||||
-- the camera with them -- so the composition on screen is identical and only
|
||||
-- the ground under it is different.
|
||||
--
|
||||
-- It buys two things.
|
||||
--
|
||||
-- A footprint that FITS. The wide shape is three cells by six; an east-west
|
||||
-- corridor two cells deep has no room for it standing up and all the room in
|
||||
-- the world for it lying down. Half the maps in Kanto run the other way from
|
||||
-- the one shape this mode was drawn in.
|
||||
--
|
||||
-- And a BACKDROP. A quarter turn moves the camera to a different side of the
|
||||
-- same patch of ground, so the wall behind the pair becomes the window
|
||||
-- behind them, or the cliff becomes the valley. Nothing about the shot's
|
||||
-- geometry changes -- the mons land on the same two screen anchors at the
|
||||
-- same size -- so this is purely a choice about what is behind them, made
|
||||
-- per map by somebody looking at it.
|
||||
--
|
||||
-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
|
||||
-- what it is; handled as quarter turns everywhere below.
|
||||
local function quarters(turn)
|
||||
local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
|
||||
return ((q % 4) + 4) % 4
|
||||
end
|
||||
|
||||
BattleArena.quarters = quarters
|
||||
|
||||
-- The footprint a shape covers once turned: a quarter or three of a turn
|
||||
-- swaps how far it reaches in each direction, which is the whole reason a
|
||||
-- corridor takes one and not the other.
|
||||
function BattleArena.extent(shape, turn)
|
||||
if quarters(turn) % 2 == 1 then return shape.h, shape.w end
|
||||
return shape.w, shape.h
|
||||
end
|
||||
|
||||
-- Where a cell offset inside the shape ends up under the same turn, measured
|
||||
-- from the turned footprint's own north-west corner -- so the corner an entry
|
||||
-- names stays the corner, whichever way the fight faces from it.
|
||||
local function spin(shape, turn, ox, oy)
|
||||
local q = quarters(turn)
|
||||
if q == 1 then return shape.h - 1 - oy, ox end
|
||||
if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
|
||||
if q == 3 then return oy, shape.w - 1 - ox end
|
||||
return ox, oy
|
||||
end
|
||||
|
||||
-- Whether a cell is open ground for the purpose above.
|
||||
--
|
||||
-- "Open" is the walk test the player themselves answer to, so an arena can
|
||||
@@ -174,12 +225,19 @@ end
|
||||
|
||||
-- Build the record the renderer reads: the two mons' cells and, in world
|
||||
-- pixels, the centre of each and of the pair.
|
||||
local function place(shape, x, y)
|
||||
local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
|
||||
local px, py = x + shape.player[1], y + shape.player[2]
|
||||
local function place(shape, x, y, turn)
|
||||
local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
|
||||
local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
|
||||
local ex, ey = x + eox, y + eoy
|
||||
local px, py = x + pox, y + poy
|
||||
local w, h = BattleArena.extent(shape, turn)
|
||||
local arena = {
|
||||
shape = shape.id,
|
||||
x = x, y = y, w = shape.w, h = shape.h,
|
||||
-- carried in degrees, so everything downstream that reasons about the
|
||||
-- shot -- the camera's base yaw above all -- reads the same number the
|
||||
-- data file was written with
|
||||
turn = quarters(turn) * 90,
|
||||
x = x, y = y, w = w, h = h,
|
||||
enemyCell = { ex, ey },
|
||||
playerCell = { px, py },
|
||||
-- world-pixel centres of the two cells a mon stands on
|
||||
@@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
|
||||
-- ocean rather than on a scrap of beach at the edge of the map. Land
|
||||
-- entries are unaffected: land passes the test either way.
|
||||
local grid, gw = openGrid(host, true)
|
||||
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
|
||||
local arena = place(shape, pick.x, pick.y)
|
||||
-- measured against the TURNED footprint: an entry that lies the arena
|
||||
-- down an east-west corridor covers different ground from the one that
|
||||
-- stands it up, and the fit test is the thing that has to know
|
||||
local fw, fh = BattleArena.extent(shape, pick.turn)
|
||||
if fits(grid, gw, pick.x, pick.y, fw, fh) then
|
||||
local arena = place(shape, pick.x, pick.y, pick.turn)
|
||||
arena.map = host
|
||||
-- which camera rig this spot is framed for; nil is the default long
|
||||
-- lens, "close" the short one small rooms need (see BattleCam)
|
||||
@@ -321,9 +383,11 @@ end
|
||||
-- The arena at a given north-west corner, whatever the map says about it.
|
||||
-- The authoring tool's manual override: a spot chosen by eye rather than by
|
||||
-- the search, so it can be photographed and judged before it is written down.
|
||||
function BattleArena.at(x, y, shapeId)
|
||||
function BattleArena.at(x, y, shapeId, turn)
|
||||
for _, shape in ipairs(BattleArena.SHAPES) do
|
||||
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
|
||||
if shape.id == (shapeId or "wide") then
|
||||
return place(shape, x, y, turn)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
+21
-2
@@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical)
|
||||
-- arena's own axis, and the room the player has is all on the far side of
|
||||
-- that -- out toward square-on. (orbitRange measures exactly that room.)
|
||||
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
|
||||
local yaw = steer + (fixed and 0
|
||||
-- ------- and the quarter turn the arena itself is standing at
|
||||
--
|
||||
-- An arena may be laid down any of the four ways (BattleArena's `turn`),
|
||||
-- and the rig is solved for ONE of them: eye off the player's shoulder,
|
||||
-- back down an axis that runs north-south. So the whole offset is turned
|
||||
-- with the ground under it, which leaves the camera in exactly the same
|
||||
-- place RELATIVE to the two mons -- same distance, same height, same
|
||||
-- angle -- and therefore lands them on the same two screen anchors at the
|
||||
-- same size. A turn is a fact about the map, never about the shot.
|
||||
--
|
||||
-- It goes in with the drift and the steer rather than beside them because
|
||||
-- it is the same rotation about the same point; the player's own orbit is
|
||||
-- then measured from wherever the arena starts, so both stops travel with
|
||||
-- it and side-on stays side-on.
|
||||
local base = math.rad(arena.turn or 0)
|
||||
local yaw = base + steer + (fixed and 0
|
||||
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
-- the breath scales the whole offset, height included, so the eye moves
|
||||
@@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical)
|
||||
local dz = (R.side * s + R.back * c) * k
|
||||
|
||||
local eye = { mx + dx, groundY + R.height * k, mz + dz }
|
||||
local focus = { mx + R.lookX, groundY + R.lookY, mz }
|
||||
-- the aim's own offset turns with the arena too, and with the BASE alone --
|
||||
-- the drift and the steer swing the eye about the focus, so a focus that
|
||||
-- followed them would take the thing being orbited around with it
|
||||
local bc, bs = math.cos(base), math.sin(base)
|
||||
local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
|
||||
|
||||
-- and the climb: the eye swung UP about the focus, at a constant radius.
|
||||
-- About the focus so the aim stays nailed to the two mons and only the
|
||||
|
||||
+120
-16
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
|
||||
|
||||
local BattleScene = {}
|
||||
|
||||
-- ------- LET'S GO capture mode's stake in this scene
|
||||
--
|
||||
-- One table while a capture session runs, nil otherwise (see
|
||||
-- lib/CatchThrow.lua, which owns it):
|
||||
--
|
||||
-- hidePlayer the player's side stays out of the shot entirely -- no
|
||||
-- card here, no model (Stadium reads this same table), no
|
||||
-- pinned back pic (OverworldBattle reads it too)
|
||||
-- shrink the foe's scale while the ball drinks it in, applied
|
||||
-- about its chest so it collapses toward the beam
|
||||
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
|
||||
-- depth buffer, same flash window, same camera
|
||||
-- cast(sm) the same ball into the sun's pass
|
||||
-- sig() a term for the cached shadow signature, so a ball in
|
||||
-- flight re-casts and a resting scene does not
|
||||
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
|
||||
-- BattleState:draw wrap in the GB frame
|
||||
--
|
||||
-- It lives HERE, not on OverworldBattle, because every consumer below
|
||||
-- already requires BattleScene and the one file that writes it requires
|
||||
-- both -- this is the spot with no require cycle.
|
||||
BattleScene.capture = nil
|
||||
|
||||
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
|
||||
-- is solved against.
|
||||
BattleScene.GB_W = 160
|
||||
@@ -195,14 +218,29 @@ end
|
||||
local function monCards(arena, groundY, textures)
|
||||
local out = {}
|
||||
if not textures then return out end
|
||||
local cap = BattleScene.capture
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local tex = textures[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
-- capture mode: the player's side is out of the shot (the seat looks
|
||||
-- over an empty shoulder), and OverworldBattle.textures already
|
||||
-- skipped rendering it -- this is the belt to that suspender
|
||||
if side == "player" and cap and cap.hidePlayer then tex = nil end
|
||||
if tex and tex.canvas and cell then
|
||||
local mirror = (side == "player") and not tex.trainer
|
||||
out[#out + 1] = { tex = tex.canvas,
|
||||
model = monMatrix(tex, cell[1], groundY, cell[2],
|
||||
mirror) }
|
||||
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
|
||||
-- the foe drinking into the ball: scaled about its own chest, in
|
||||
-- world space so the composed card matrix needs no decomposition
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
local k = cap.shrink
|
||||
local ax, ay, az = cell[1], groundY + 8, cell[2]
|
||||
model = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(ax, ay, az),
|
||||
Mat4.mul(Mat4.scale(k, k, k),
|
||||
Mat4.translate(-ax, -ay, -az))),
|
||||
model)
|
||||
end
|
||||
out[#out + 1] = { tex = tex.canvas, model = model }
|
||||
end
|
||||
end
|
||||
return out
|
||||
@@ -303,12 +341,24 @@ end
|
||||
-- first drawn in.
|
||||
local function shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
local host = arena.map or state.map
|
||||
-- `turn` is in the signature with the corner and the shape: the same corner
|
||||
-- turned a quarter is a different footprint standing on different ground,
|
||||
-- and a cast kept from the other one freezes the shadows across it
|
||||
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
|
||||
tostring(arena.turn or 0),
|
||||
tostring(terrain), tostring(token or 0),
|
||||
-- the cycle keeps running through a fight, and an arena lit
|
||||
-- from somewhere new must be re-cast from there
|
||||
math.floor(ShadowMap.KX * 128),
|
||||
math.floor(ShadowMap.KZ * 128) }
|
||||
-- a capture session's ball moves through the sun's world too; its term
|
||||
-- is quantised inside sig() so the cache re-renders on real movement
|
||||
-- and not on every frame the ball rests
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.sig then
|
||||
local okSig, sig = pcall(cap.sig)
|
||||
parts[#parts + 1] = okSig and sig or "cap"
|
||||
end
|
||||
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
|
||||
return table.concat(parts, ",")
|
||||
end
|
||||
@@ -373,6 +423,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
-- the water. Un-snugged for the same reason: snug is a bias for a card
|
||||
-- rooted to the ground plane, and a model has thickness of its own.
|
||||
pcall(function() V.require("Stadium").cast(ShadowMap) end)
|
||||
-- the capture session's ball, by the same reasoning: real geometry, its
|
||||
-- shadow is half of what sells the arc
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.cast then pcall(cap.cast, ShadowMap) end
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -475,6 +529,17 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- no glint in the arena: the drift is the shot breathing, not the player
|
||||
-- moving, and a shimmer on background windows would fight the mons
|
||||
Voxel3D.glassGlint = 0
|
||||
-- the host floor's atmosphere reaches the staged shot at HALF density --
|
||||
-- a fight in Viridian Forest sits in the same haze the walk there did,
|
||||
-- thinned so neither mon goes soft -- and its god rays stay out of it:
|
||||
-- this camera is low and long, and a bright blade across a combatant
|
||||
-- reads as a rendering fault, not weather. nil almost everywhere.
|
||||
local ForestAtmos = V.require("ForestAtmos")
|
||||
local atmos = ForestAtmos.frame(host)
|
||||
Voxel3D.fog = atmos and { color = atmos.fog.color,
|
||||
density = atmos.fog.density * 0.5,
|
||||
start = atmos.fog.start,
|
||||
heightK = atmos.fog.heightK } or nil
|
||||
|
||||
-- A B RUNG stands the fight on two carried discs against the sky, with no
|
||||
-- map in the shot at all (see StadiumStage). Everything below still runs --
|
||||
@@ -505,7 +570,18 @@ function BattleScene.render(state, arena, textures, token)
|
||||
end
|
||||
|
||||
local groundY = BattleScene.groundY(host, arena)
|
||||
local cam, pitch = BattleCam.rig(arena, groundY)
|
||||
-- A capture session brings a camera of its own: the head-on seat, on
|
||||
-- the arena's axis looking straight at the foe, in place of the solved
|
||||
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
|
||||
-- the pins, the sun, the cards yawing to the eye -- is generic over
|
||||
-- whichever camera this is.
|
||||
local cam, pitch, capFrameH
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.rig then
|
||||
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
|
||||
if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
|
||||
end
|
||||
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
|
||||
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
|
||||
|
||||
local cx, cy = arena.mid[1], arena.mid[2]
|
||||
@@ -514,7 +590,8 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- the player's zoom is part of this: the sun's box is fitted to what the
|
||||
-- frame holds, so a shot pulled wide has to light the ground it just
|
||||
-- brought into view rather than the ground the rig alone would have
|
||||
local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
|
||||
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
|
||||
/ (BattleScene.GB_H * s)
|
||||
local vw = vh * pw / ph
|
||||
|
||||
-- the cards need the camera's eye to face it, so the rig has to be live
|
||||
@@ -647,6 +724,11 @@ function BattleScene.render(state, arena, textures, token)
|
||||
V.require("Stadium").draw(BattleBillboard.PULL)
|
||||
end)
|
||||
if not okStadium then V.require("Stadium").report(stadiumErr) end
|
||||
-- the capture session's Poke Ball, still inside the flash window and
|
||||
-- with the mons' own camera-ward pull, so a ball crossing in front of
|
||||
-- a card wins the depth test the way a nearer thing should
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
|
||||
if flashing then Voxel3D.flatten(nil) end
|
||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||
-- gives them, measured against THIS camera's pitch rather than the
|
||||
@@ -680,25 +762,47 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- How wide one overworld square is on screen where each mon stands, in
|
||||
-- GB pixels. This is what the pics are scaled to: a mon covers its own
|
||||
-- square and no more, at whatever the drift has done to the distance.
|
||||
--
|
||||
-- Measured along BOTH map axes and answered as the larger, as a full
|
||||
-- 2D screen distance. One axis alone breaks the moment a camera looks
|
||||
-- ALONG it: the capture seat stands on the arena's own axis, and on a
|
||||
-- quarter-turned arena that axis is world X -- the ±X probe points
|
||||
-- then project to the same pixel and the span reads zero, which
|
||||
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
|
||||
local half = BattleScene.CELL / 2
|
||||
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
if not (pl and pr and el and er) then return end
|
||||
local function cellSpan(wx, wz)
|
||||
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
|
||||
lx, ly, s, pw, ph)
|
||||
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
|
||||
lx, ly, s, pw, ph)
|
||||
if not (x1 and x2 and x3 and x4) then return nil end
|
||||
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
|
||||
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
|
||||
return math.max(ew, ns)
|
||||
end
|
||||
local pSpan = cellSpan(arena.player[1], arena.player[2])
|
||||
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
|
||||
if not (pSpan and eSpan) then return end
|
||||
out = {
|
||||
canvas = canvas,
|
||||
player = { pmx, pmy },
|
||||
enemy = { emx, emy },
|
||||
playerSpan = math.abs(pr - pl),
|
||||
enemySpan = math.abs(er - el),
|
||||
playerSpan = pSpan,
|
||||
enemySpan = eSpan,
|
||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
||||
-- the two marks rather than on a fraction of the window
|
||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
||||
-- the camera and its combined matrix, for anything that reasons
|
||||
-- about this shot from outside the render -- the capture mode's
|
||||
-- throw is solved in these (aim errors along this eye's own right
|
||||
-- and forward, contact judged through this vp)
|
||||
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
|
||||
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
|
||||
vp = vp,
|
||||
-- and the hour's light, for anything drawn over this shot that is NOT
|
||||
-- geometry and so never went past the shader that applied it -- the back
|
||||
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
|
||||
|
||||
+118
-6
@@ -264,6 +264,27 @@ local function measure(sp, t)
|
||||
top[x] = r
|
||||
end
|
||||
|
||||
-- The row a column's roof SURFACE may sink to. `top[x]` is the
|
||||
-- silhouette cap -- the black the drawing closes its shape with -- and
|
||||
-- the depth map spends most of a tapered column's depth above it, so
|
||||
-- clamping onto `top[x]` paints that one outline pixel the length of
|
||||
-- the slope and the courses beat against it. The surface belongs on the
|
||||
-- first PAINTED row instead: the same refusal to let the outline stand
|
||||
-- as a face that the side faces already make below.
|
||||
local surfaceTop = {}
|
||||
for x = 0, W - 1 do
|
||||
local y = top[x]
|
||||
while y < roofRows and sp.inside[y * W + x]
|
||||
and sp.col[y * W + x] == BLACK do
|
||||
y = y + 1
|
||||
end
|
||||
if y < roofRows and sp.inside[y * W + x] then
|
||||
surfaceTop[x] = y
|
||||
else
|
||||
surfaceTop[x] = top[x]
|
||||
end
|
||||
end
|
||||
|
||||
-- The drawing's own ground line: the row after the last drawn one. A
|
||||
-- building ends on the black threshold row it stands on (ground == H),
|
||||
-- but furniture is drawn standing on open floor -- the lab table's
|
||||
@@ -386,7 +407,7 @@ local function measure(sp, t)
|
||||
-- building. `depthPx` names it in voxels, for an object whose real
|
||||
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
|
||||
-- standing in the middle of its own cell, not a thing that fills a plot.
|
||||
return { top = top, ytop = ytop,
|
||||
return { top = top, surfaceTop = surfaceTop, ytop = ytop,
|
||||
D = t.depthPx or ((t.depth or #t.tiles) * 8),
|
||||
ground = ground,
|
||||
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
||||
@@ -436,7 +457,8 @@ local function deskSetModel(sp, pr, t)
|
||||
local function buildParts(plane)
|
||||
for _, p in ipairs(t.parts) do
|
||||
Budget.tick()
|
||||
local x0, x1 = p.x[1], p.x[2]
|
||||
local x0 = p.x and p.x[1] or 0
|
||||
local x1 = p.x and p.x[2] or (W - 1)
|
||||
if p.kind == "flat" then
|
||||
-- drawn row = depth row by default; `z` renames the origin when
|
||||
-- the flat sits below the desk's own drawn top span (the Center
|
||||
@@ -561,6 +583,94 @@ local function deskSetModel(sp, pr, t)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "plan" then
|
||||
-- A PLAN part is a slab whose plan IS the drawn top view: the
|
||||
-- band's silhouette becomes the footprint pixel for pixel
|
||||
-- (drawn row = depth row, the same 1:1 every tabletop is drawn
|
||||
-- with), so an octagonal top stands as an octagon rather than
|
||||
-- the box no rectangular band can escape. The top layer wears
|
||||
-- the band itself, outline and all; the rim layers below wear
|
||||
-- the drawn fascia rows folded down the edge (x clamped into
|
||||
-- the drawn fascia's span), and the slab's unseen interior the
|
||||
-- field's dark texel.
|
||||
local r0, r1 = p.rows[1], p.rows[2]
|
||||
local f0, f1 = p.fascia[1], p.fascia[2]
|
||||
local fx0, fx1 = p.fasciaX[1], p.fasciaX[2]
|
||||
local rise = p.rise or 0
|
||||
local h = (f1 - f0 + 1) + 1
|
||||
if rise + h > ytop then ytop = rise + h end
|
||||
local function drawn(sx, z)
|
||||
return sx >= x0 and sx <= x1 and z >= 0 and z <= r1 - r0
|
||||
and inside[(r0 + z) * W + sx]
|
||||
end
|
||||
for z = 0, r1 - r0 do
|
||||
if z >= 0 and z < D then
|
||||
local sy = r0 + z
|
||||
for sx = x0, x1 do
|
||||
if inside[sy * W + sx] then
|
||||
put(sx, rise + h - 1, z, sy * W + sx)
|
||||
local edge = not (drawn(sx - 1, z) and drawn(sx + 1, z)
|
||||
and drawn(sx, z - 1) and drawn(sx, z + 1))
|
||||
for y = rise, rise + h - 2 do
|
||||
if edge then
|
||||
local fsx = math.max(fx0, math.min(fx1, sx))
|
||||
put(sx, y, z, (f0 + (rise + h - 2 - y)) * W + fsx)
|
||||
else
|
||||
put(sx, y, z, pr.shadeTexel[DARK])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "disc" then
|
||||
-- A DISC part is ROUND IN PLAN -- the pedestal column and base
|
||||
-- the projection can only draw from the front. Centre and
|
||||
-- radius are measured off the drawn widths (a flattened arc is
|
||||
-- a horizontal circle seen from above); the circular footprint
|
||||
-- is synthesized like any continued geometry, and every voxel
|
||||
-- still wears the drawing: the side folds the drawn face-on
|
||||
-- rows around the hull (x clamped into the drawn span, rows
|
||||
-- repeating up the height), and `cap` lays the drawn top-view
|
||||
-- rows over the top layer's interior, drawn north rows to the
|
||||
-- plan's north. `cx2`/`cz2` are DOUBLED plan centres, so an
|
||||
-- even diameter keeps its centre between two voxels instead of
|
||||
-- limping one off.
|
||||
local r, rise, h = p.r, p.rise or 0, p.h
|
||||
local s0, s1 = p.side.rows[1], p.side.rows[2]
|
||||
local sa0, sa1 = p.side.x[1], p.side.x[2]
|
||||
local sn = s1 - s0 + 1
|
||||
if rise + h > ytop then ytop = rise + h end
|
||||
local function inDisc(x, z)
|
||||
local dx = 2 * x + 1 - p.cx2
|
||||
local dz = 2 * z + 1 - p.cz2
|
||||
return dx * dx + dz * dz <= 4 * r * r
|
||||
end
|
||||
local zlo = math.floor((p.cz2 - 2 * r) / 2)
|
||||
for x = math.floor((p.cx2 - 2 * r) / 2),
|
||||
math.floor((p.cx2 + 2 * r) / 2) do
|
||||
for z = math.max(0, zlo),
|
||||
math.min(D - 1, math.floor((p.cz2 + 2 * r) / 2)) do
|
||||
if inDisc(x, z) then
|
||||
local edge = not (inDisc(x - 1, z) and inDisc(x + 1, z)
|
||||
and inDisc(x, z - 1) and inDisc(x, z + 1))
|
||||
for y = rise, rise + h - 1 do
|
||||
local sx, sy
|
||||
if p.cap and y == rise + h - 1 and not edge then
|
||||
local c0, c1 = p.cap.rows[1], p.cap.rows[2]
|
||||
sy = math.min(c1, c0 + math.floor((z - zlo)
|
||||
* (c1 - c0 + 1)
|
||||
/ (2 * r)))
|
||||
sx = math.max(p.cap.x[1], math.min(p.cap.x[2], x))
|
||||
else
|
||||
sy = s0 + (rise + h - 1 - y) % sn
|
||||
sx = math.max(sa0, math.min(sa1, x))
|
||||
end
|
||||
put(x, y, z, sy * W + sx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local tr0, tr1 = p.top[1], p.top[2]
|
||||
local fr0, fr1 = p.facade[1], p.facade[2]
|
||||
@@ -882,6 +992,7 @@ local function model(sp, pr, t)
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
local top, ytop, ground = pr.top, pr.ytop, pr.ground
|
||||
local surfaceTop = pr.surfaceTop
|
||||
|
||||
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
|
||||
-- columns undrawn in the roof band; they carry no roof at all, and the
|
||||
@@ -931,11 +1042,12 @@ local function model(sp, pr, t)
|
||||
if top[x] < roofRows
|
||||
and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then
|
||||
if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then
|
||||
-- the surface itself. Clamping the row into the column's first
|
||||
-- drawn row keeps the flank battens running down the slope
|
||||
-- instead of falling off the silhouette.
|
||||
-- the surface itself. Lifting the row into the column's first
|
||||
-- PAINTED row keeps the flank battens running down the slope
|
||||
-- instead of falling off the silhouette -- and off its cap, which
|
||||
-- is outline black and belongs to the rim, not to the surface.
|
||||
local sy = roofSy[z]
|
||||
if sy < top[x] then sy = top[x] end
|
||||
if sy < surfaceTop[x] then sy = surfaceTop[x] end
|
||||
return sy * W + x
|
||||
end
|
||||
-- The rim reproduces the eave the drawing itself paints under the
|
||||
|
||||
+1605
File diff suppressed because it is too large
Load Diff
+344
@@ -0,0 +1,344 @@
|
||||
-- The DIORAMA modes: Kanto as a model you can pick up.
|
||||
--
|
||||
-- STANDARD VR presents whatever rung the player is on -- the orbit rungs
|
||||
-- become a tabletop, 1ST stands you inside the world (see lib/VR.lua).
|
||||
-- DIORAMA is a different promise, and it is one promise rather than a
|
||||
-- ladder: the world is ALWAYS the model on the table, seen from outside,
|
||||
-- and what the headset adds is that the model is a THING IN THE ROOM --
|
||||
-- grab it, turn it, set it down somewhere else, decide how much of it you
|
||||
-- want to be holding.
|
||||
--
|
||||
-- Two pieces make that read, and this file owns both.
|
||||
--
|
||||
-- THE VIEWPORT. 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. A square cut with a HARD edge, because a flat world is a
|
||||
-- thing with sides and the sides are what say so.
|
||||
--
|
||||
-- V-CURVE is what 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 a globe is a lie about what is
|
||||
-- being looked at. So the box becomes a BALL, and its rim becomes a
|
||||
-- GRADIENT that dissolves into the sky rather than an edge that
|
||||
-- guillotines it. One click of the left stick (which throws V-CURVE --
|
||||
-- see lib/VR) swaps between the two readings of the same model.
|
||||
--
|
||||
-- A staged fight ignores both and cuts a vertical PILLAR about the
|
||||
-- arena, which lifts the fight out of the map as a floating disc.
|
||||
--
|
||||
-- (A BASE was built under all this once -- the ground extruded a tile
|
||||
-- deep, cut to the viewport's shape, wearing Mt Moon's cave floor down
|
||||
-- its sides -- and it was REMOVED at the user's request. The cut ends at
|
||||
-- the ground plane now; don't put a plinth back under it.)
|
||||
--
|
||||
-- THE GRIP. Squeeze one and the model follows that hand through the
|
||||
-- room; squeeze both and it turns with them and the viewport resizes
|
||||
-- to whatever you open your hands to. All of it is arithmetic on the
|
||||
-- XR-to-world mapping lib/VRRig already had (an anchor, a yaw and a
|
||||
-- scale), so nothing about the world's own geometry knows this is
|
||||
-- happening.
|
||||
--
|
||||
-- DIORAMA-MR is the same mode with the background keyed pure green, for
|
||||
-- a mixed-reality capture that composites the model into the room the
|
||||
-- player is actually standing in.
|
||||
--
|
||||
-- Nothing here reaches the flat screen: every field is set by lib/VR for
|
||||
-- the length of one headset frame and cleared with the session.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Diorama = {}
|
||||
|
||||
-- ------- the viewport
|
||||
--
|
||||
-- The half-size at rest, as a fraction of the view height the flat screen
|
||||
-- frames. Sized off the VIEW rather than fixed in world pixels so the zoom
|
||||
-- rows keep meaning what they mean -- a zoomed-in rung frames less world
|
||||
-- and gets a smaller model, exactly as it frames a smaller picture.
|
||||
--
|
||||
-- The BOX takes half of it, so the square is exactly the view the standard
|
||||
-- rung would have shown, edge to edge. The BALL takes rather more: a ball
|
||||
-- inscribed in that square holds noticeably less world (its corners are
|
||||
-- the four biggest pieces of it), and the point of the V-CURVE throw is to
|
||||
-- see the same model curl, not to lose a quarter of it.
|
||||
Diorama.BOX_FRAC = 0.5
|
||||
Diorama.BALL_FRAC = 0.62
|
||||
|
||||
-- How far the grips may open and close it, as a multiplier on that.
|
||||
Diorama.SCALE_MIN = 0.3
|
||||
Diorama.SCALE_MAX = 4
|
||||
|
||||
-- The rim under V-CURVE, as a fraction of the radius: where the world
|
||||
-- starts fading and where it has finished. Wide enough to read as a
|
||||
-- dissolve rather than an edge, narrow enough that the middle of the model
|
||||
-- is solid. The BOX has no fade at all -- see fadeFor.
|
||||
Diorama.FADE_FRAC = 0.16
|
||||
|
||||
-- The staged fight's disc: the arena's own half-length plus an apron, in
|
||||
-- world pixels (a map cell is 16). The two mons stand three cells apart,
|
||||
-- so this is a disc about seven cells across -- the fight, the ground it
|
||||
-- is fought on, and nothing else.
|
||||
Diorama.ARENA_APRON = 32
|
||||
|
||||
-- ------- what the live frame is
|
||||
--
|
||||
-- All three set by lib/VR for the length of one headset frame, and by
|
||||
-- nothing else. `on` is the whole mode's gate; VoxelScene reads it once
|
||||
-- per frame and every diorama-shaped thing hangs off that read.
|
||||
Diorama.on = false
|
||||
Diorama.keyed = false
|
||||
Diorama.cull = nil -- { x, y, z, r, invFade, kind }
|
||||
|
||||
-- Chroma green, and PURE green deliberately: a keyer wants the one colour
|
||||
-- nothing in the picture can accidentally be, and no palette this mod can
|
||||
-- paint the world in reaches 0,255,0.
|
||||
Diorama.KEY_COLOR = { 0, 1, 0 }
|
||||
|
||||
-- ------- what the grips have done to it
|
||||
--
|
||||
-- Kept across frames (this is where the model IS, as far as the player is
|
||||
-- concerned) and cleared only when the session ends. `offset` is in LOCAL
|
||||
-- metres and rides the mapping's anchor, `yaw` turns the mapping, `zoom`
|
||||
-- multiplies the viewport's radius.
|
||||
Diorama.offset = { 0, 0, 0 }
|
||||
Diorama.yaw = 0
|
||||
Diorama.zoom = 1
|
||||
|
||||
function Diorama.reset()
|
||||
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
|
||||
Diorama.offset = { 0, 0, 0 }
|
||||
Diorama.yaw, Diorama.zoom = 0, 1
|
||||
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)
|
||||
Diorama.on = (mode == "diorama" or mode == "diorama-mr")
|
||||
Diorama.keyed = Diorama.on and mode == "diorama-mr"
|
||||
if not Diorama.on then Diorama.cull = nil end
|
||||
return Diorama.on
|
||||
end
|
||||
|
||||
function Diorama.stop()
|
||||
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
|
||||
end
|
||||
|
||||
-- What the world's background must be cleared to, or nil to leave the sky
|
||||
-- alone. Only ever a colour in DIORAMA-MR, and only while a frame is open.
|
||||
function Diorama.keyColor()
|
||||
if not (Diorama.on and Diorama.keyed) then return nil end
|
||||
return Diorama.KEY_COLOR
|
||||
end
|
||||
|
||||
-- ------- the viewport, as the shaders take it
|
||||
--
|
||||
-- `kind` is the shader's own switch: 0 no cut, 1 the box, 2 the ball, 3
|
||||
-- the fight's pillar. `invFade` is one over the fade band in world pixels,
|
||||
-- so the rim is a single multiply out there -- and a hard edge is simply a
|
||||
-- band under a pixel wide, which costs the shader no branch of its own.
|
||||
Diorama.BOX = 1
|
||||
Diorama.BALL = 2
|
||||
Diorama.PILLAR = 3
|
||||
|
||||
-- The half-size the viewport stands at right now, for a view `vh` world
|
||||
-- pixels tall -- the flat framing this rung would have shown -- and for
|
||||
-- the shape it is currently in.
|
||||
function Diorama.radius(vh, curved)
|
||||
local frac = curved and Diorama.BALL_FRAC or Diorama.BOX_FRAC
|
||||
return math.max(24, (vh or 288) * frac * Diorama.zoom)
|
||||
end
|
||||
|
||||
-- Whether the world is BENT right now, which is the whole of what decides
|
||||
-- the viewport's shape: a square cut suits a flat slab of map, and a
|
||||
-- curved world rolling away over its own horizon wants a ball with a
|
||||
-- dissolve. Asked of the row rather than remembered, so the V-CURVE the
|
||||
-- stick click throws (and the "7" key, and the OPTIONS row) all reach it.
|
||||
function Diorama.curved()
|
||||
local ok, on = pcall(function()
|
||||
return V.require("WorldCurve").active()
|
||||
end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
-- The fade band for a cut of half-size `r`: the curve's dissolve, or a
|
||||
-- hard edge (band 0) for the box.
|
||||
function Diorama.fadeFor(r, curved)
|
||||
if not curved then return 0 end
|
||||
return math.max(1, r * Diorama.FADE_FRAC)
|
||||
end
|
||||
|
||||
local function volume(kind, x, y, z, r, fade)
|
||||
return { x = x, y = y, z = z, r = r,
|
||||
-- The BOX kind is rectangular in the shader, because the flat
|
||||
-- screen's box is the WINDOW's own footprint and a window is not
|
||||
-- square (lib/ViewBox). A headset's model has no window to be
|
||||
-- shaped like, so this one is: the same half-size twice.
|
||||
rx = r, rz = r,
|
||||
-- a zero band is a hard edge: half a pixel of ramp, which is
|
||||
-- one pixel of antialiasing rather than a stair
|
||||
invFade = 1 / math.max(fade or 0, 0.5), kind = kind }
|
||||
end
|
||||
|
||||
-- The viewport this frame, centred on the world point the model is pinned
|
||||
-- by: the BOX ordinarily, and the BALL while the world is curved.
|
||||
function Diorama.viewport(cx, cy, vh)
|
||||
local curved = Diorama.curved()
|
||||
local r = Diorama.radius(vh, curved)
|
||||
Diorama.cull = volume(curved and Diorama.BALL or Diorama.BOX,
|
||||
cx, 0, cy, r, Diorama.fadeFor(r, curved))
|
||||
return Diorama.cull
|
||||
end
|
||||
|
||||
-- The staged fight's disc: a vertical pillar about the arena's midpoint,
|
||||
-- wide enough for both mons and their apron. Vertical means UNBOUNDED --
|
||||
-- a tree standing on the disc keeps all of its height, which is what
|
||||
-- makes the cut read as the ground having been lifted out rather than as
|
||||
-- the world having been sliced through at eye level.
|
||||
function Diorama.pillar(arena)
|
||||
if not (arena and arena.mid) then return nil end
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
local r = Diorama.ARENA_APRON
|
||||
if arena.player and arena.enemy then
|
||||
local dx = arena.player[1] - mx
|
||||
local dz = arena.player[2] - mz
|
||||
r = r + math.sqrt(dx * dx + dz * dz)
|
||||
end
|
||||
-- Round whatever the curve is doing -- a fight is a disc, and a square
|
||||
-- arena tile floating in the air is not the picture -- and ALWAYS
|
||||
-- dissolved at the rim, curve or no curve. The box's hard edge is there
|
||||
-- to say "this is a flat slab of map with sides"; a fight is a thing
|
||||
-- lifted out of the world and hanging in the air, and a hard edge on it
|
||||
-- reads as a cookie cutter rather than as a piece of ground.
|
||||
Diorama.cull = volume(Diorama.PILLAR, mx, 0, mz, r,
|
||||
Diorama.fadeFor(r, true))
|
||||
return Diorama.cull
|
||||
end
|
||||
|
||||
-- ------- the grips
|
||||
--
|
||||
-- One hand carries the model; two turn it and open the viewport. The
|
||||
-- gesture is measured as a DELTA per frame rather than from where the
|
||||
-- squeeze started, so letting go and taking hold again never snaps
|
||||
-- anything -- the model simply stops following and starts again.
|
||||
|
||||
Diorama.GRIP = 0.6 -- squeezed past this counts as holding on
|
||||
Diorama.SPREAD_MIN = 0.08 -- hands closer than this give no scale
|
||||
|
||||
local lastOne = nil -- the carrying hand's position, last frame
|
||||
local lastMid = nil -- both hands' midpoint
|
||||
local lastAngle = nil -- and the bearing of the line between them
|
||||
local lastSpread = nil -- and its length
|
||||
|
||||
local function clearGrab()
|
||||
lastOne, lastMid, lastAngle, lastSpread = nil, nil, nil, nil
|
||||
end
|
||||
|
||||
Diorama.releaseGrab = clearGrab
|
||||
|
||||
-- Advance the grab from this frame's controller state (lib/VRXR's table:
|
||||
-- gripL/gripR in 0..1, handl/handr as { pos, quat } when tracked).
|
||||
-- Returns true while the model is being held.
|
||||
function Diorama.gesture(ctl)
|
||||
if not ctl then
|
||||
clearGrab()
|
||||
return false
|
||||
end
|
||||
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
|
||||
local hl = (gl > Diorama.GRIP) and ctl.handl or nil
|
||||
local hr = (gr > Diorama.GRIP) and ctl.handr or nil
|
||||
|
||||
if hl and hr then
|
||||
lastOne = nil
|
||||
local lp, rp = hl.pos, hr.pos
|
||||
local mid = { (lp[1] + rp[1]) / 2, (lp[2] + rp[2]) / 2,
|
||||
(lp[3] + rp[3]) / 2 }
|
||||
local dx, dy, dz = rp[1] - lp[1], rp[2] - lp[2], rp[3] - lp[3]
|
||||
local spread = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
-- the bearing of the line between the hands, in the same convention
|
||||
-- the mapping's yaw turns through (see VRRig.eyeCamera): atan2 of the
|
||||
-- x component over the z one, so a hand-over-hand turn and the model's
|
||||
-- turn are the same number
|
||||
local angle = math.atan2(dx, dz)
|
||||
if lastMid then
|
||||
for i = 1, 3 do
|
||||
Diorama.offset[i] = Diorama.offset[i] + (mid[i] - lastMid[i])
|
||||
end
|
||||
end
|
||||
if lastAngle then
|
||||
local d = (angle - lastAngle + math.pi) % (2 * math.pi) - math.pi
|
||||
Diorama.yaw = (Diorama.yaw + d + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
if lastSpread and lastSpread > Diorama.SPREAD_MIN
|
||||
and spread > Diorama.SPREAD_MIN then
|
||||
Diorama.zoom = math.max(Diorama.SCALE_MIN,
|
||||
math.min(Diorama.SCALE_MAX,
|
||||
Diorama.zoom * (spread / lastSpread)))
|
||||
end
|
||||
lastMid, lastAngle, lastSpread = mid, angle, spread
|
||||
return true
|
||||
end
|
||||
|
||||
lastMid, lastAngle, lastSpread = nil, nil, nil
|
||||
local one = hl or hr
|
||||
if one then
|
||||
if lastOne then
|
||||
for i = 1, 3 do
|
||||
Diorama.offset[i] = Diorama.offset[i] + (one.pos[i] - lastOne[i])
|
||||
end
|
||||
end
|
||||
lastOne = { one.pos[1], one.pos[2], one.pos[3] }
|
||||
return true
|
||||
end
|
||||
lastOne = nil
|
||||
return false
|
||||
end
|
||||
|
||||
-- Nothing here owns a GPU object any more (the base did, and it is gone --
|
||||
-- see the header), so this is only the grab's own hand-to-hand state: a
|
||||
-- window resize or a hot reload should not leave the model following a
|
||||
-- delta measured against a frame that no longer exists.
|
||||
function Diorama.release()
|
||||
clearGrab()
|
||||
end
|
||||
|
||||
Diorama.invalidate = Diorama.release
|
||||
|
||||
return Diorama
|
||||
@@ -0,0 +1,98 @@
|
||||
-- LET'S GO: the whole party's experience, on one card.
|
||||
--
|
||||
-- Sharing experience to everybody has a cost the original game never had
|
||||
-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every
|
||||
-- knockout, each needing its own press. That is the same information the
|
||||
-- player wanted, delivered in the most tiring possible way -- and it is
|
||||
-- worse than a wall of text, because the numbers arrive one at a time so
|
||||
-- the one thing a shared payout is FOR (comparing them: the little one
|
||||
-- gained five times what the big one did) can never be seen at once.
|
||||
--
|
||||
-- So the per-Pokemon lines are suppressed and this card is shown in their
|
||||
-- place: one box, one press, every gain side by side, with a level-up
|
||||
-- called out on the row it happened to. What the card cannot cover still
|
||||
-- plays as it always did -- "X grew to level 6!", the stats window, and
|
||||
-- any move learned -- because those are events, not a tally.
|
||||
--
|
||||
-- Drawn with the engine's own font and box, in the GB's own frame, so it
|
||||
-- sits in a staged 3D battle exactly like every other battle panel.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ExpPanel = {}
|
||||
ExpPanel.__index = ExpPanel
|
||||
|
||||
local Font = nil
|
||||
local function font()
|
||||
if Font ~= nil then return Font or nil end
|
||||
local ok, F = pcall(require, "src.render.Font")
|
||||
Font = ok and F or false
|
||||
return Font or nil
|
||||
end
|
||||
|
||||
-- the GB frame, in 8-pixel tiles
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 20, 18
|
||||
local ROW_H = 12 -- pixels between listed Pokemon
|
||||
|
||||
-- `rows` is filled by the award loop and read HERE, at draw time: the
|
||||
-- loop runs to completion long before the battle queue reaches this
|
||||
-- panel, so the table is always complete by the time it is shown.
|
||||
function ExpPanel.new(game, rows)
|
||||
return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel)
|
||||
end
|
||||
|
||||
function ExpPanel:update(dt)
|
||||
self.t = (self.t or 0) + (dt or 0)
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
-- a beat of deafness: the press that dismissed whatever came before
|
||||
-- must not dismiss this card in the same breath
|
||||
if self.t < 0.12 then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
function ExpPanel:draw()
|
||||
local F = font()
|
||||
if not F then return end
|
||||
local rows = self.rows or {}
|
||||
local n = #rows
|
||||
if n == 0 then return end
|
||||
|
||||
-- bottom-anchored and only as tall as it needs to be, so a two-Pokemon
|
||||
-- party does not black out the fight behind it
|
||||
local th = 3 + math.ceil(n * ROW_H / TILE)
|
||||
th = math.min(th, ROWS - 1)
|
||||
local ty = ROWS - th
|
||||
F.drawBox(0, ty, COLS, th)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local x0 = TILE
|
||||
local y0 = (ty + 1) * TILE + 2
|
||||
F.draw("EXP GAINED", x0, y0)
|
||||
|
||||
for i, r in ipairs(rows) do
|
||||
local y = y0 + ROW_H + (i - 1) * ROW_H
|
||||
if y > (ROWS - 1) * TILE then break end
|
||||
local mon = r.mon
|
||||
local name = mon.nickname
|
||||
or (self.game.data.pokemon[mon.species] or {}).name
|
||||
or tostring(mon.species)
|
||||
F.draw(name, x0, y)
|
||||
-- a level-up is called out where it happened rather than left to the
|
||||
-- message that follows, so the card reads as the whole story
|
||||
if (r.to or 0) > (r.from or 0) then
|
||||
local up = ("L%d"):format(r.to)
|
||||
F.draw(up, 108 - F.width(up), y)
|
||||
end
|
||||
local amt = ("+%d"):format(r.gained or 0)
|
||||
F.draw(amt, 152 - F.width(amt), y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return ExpPanel
|
||||
@@ -0,0 +1,886 @@
|
||||
-- The air under the canopy: fog, god rays, and what drifts through them.
|
||||
--
|
||||
-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest
|
||||
-- today, any map that adds a line tomorrow): a ground haze the scene shader
|
||||
-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down
|
||||
-- through an INVISIBLE canopy hanging above the map's real trees, as if the
|
||||
-- carved hulls on screen were only the understorey of something taller.
|
||||
--
|
||||
-- The rays are not placed geometry. A fullscreen pass marches every
|
||||
-- pixel's eye ray through the air, stops at the frame's own depth buffer
|
||||
-- (the same detach-and-read Water runs), and asks two questions of every
|
||||
-- step of air on the way:
|
||||
--
|
||||
-- * the SUN'S question -- the shadow map. Air behind a tree hull is
|
||||
-- dark air; air in a real gap glows. A trunk stands in a column of
|
||||
-- its own shade, a character walks through the beams and blocks
|
||||
-- them, and every shaft on screen agrees with the light already on
|
||||
-- the floor, because it is read from the same map.
|
||||
--
|
||||
-- * the CANOPY'S question -- where this thread of sun pierced the
|
||||
-- invisible leaf layer. Every step of air on one sun ray shares that
|
||||
-- point, which is what makes a shaft a SHAFT, and the point samples
|
||||
-- a wind-blown noise field: the dapple drifts and shivers like
|
||||
-- leaves moving overhead, opening and closing the beams as it goes.
|
||||
--
|
||||
-- The shafts lean along the fixed noon shear, deliberately: a canopy
|
||||
-- map's rig is pinned to noon (see DayNight.CANOPY), so light that
|
||||
-- followed the sun's arc would part company with every shadow on the
|
||||
-- floor. What follows the clock is colour and strength -- gold spears of
|
||||
-- sun by day, silver moon rays after dark, dying back through the
|
||||
-- twilights -- plus the crew each shift brings: pollen adrift in the
|
||||
-- day's beams, fireflies once they cool. A forward-scattering phase term
|
||||
-- brightens the beams for a camera looking up into the light, which is
|
||||
-- most of what makes them read as light in air rather than paint on it.
|
||||
--
|
||||
-- Everything is deterministic: placement and the leaf field are dealt by
|
||||
-- a seeded xorshift (StadiumFx's generator), motion is a pure function
|
||||
-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame
|
||||
-- exactly (see tests/forest_fog_shots). Every shader compiles lazily
|
||||
-- behind pcall -- nil untried, false unavailable -- and each refusal
|
||||
-- subtracts only itself: no march without readable depth, no beams
|
||||
-- without a shadow map, and the fog rides the scene shader whatever
|
||||
-- happens here.
|
||||
|
||||
local V = ...
|
||||
|
||||
local DayNight = V.require("DayNight")
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max
|
||||
|
||||
local ForestAtmos = {}
|
||||
|
||||
-- The viewport, as both shaders here take it (see Voxel3D.cull: the field
|
||||
-- is set for a headset's diorama frame and for an orbit rung's window box,
|
||||
-- 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
|
||||
|
||||
local function cullRect()
|
||||
local c = V.require("Voxel3D").cull
|
||||
return c and { c.rx or c.r, c.rz or c.r } or { 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.
|
||||
--
|
||||
-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the
|
||||
-- default. The march needs a depth texture it can READ, and no driver on
|
||||
-- the phones this runs on has granted one (see newDepth in Voxel3D) --
|
||||
-- so FULL would be a rung with nothing behind it, which reads as a
|
||||
-- broken mod rather than a missing feature. LOW there is the haze, the
|
||||
-- one part of the atmosphere that rides the scene shader and works
|
||||
-- everywhere. A desktop save opened on a phone stores FULL still;
|
||||
-- ModSetting's unknown-value fallback lands it on LOW, and putting the
|
||||
-- save back on the desktop restores the choice.
|
||||
local function onAndroid()
|
||||
if not (love and love.system and love.system.getOS) then return false end
|
||||
local ok, os = pcall(love.system.getOS)
|
||||
return ok and os == "Android"
|
||||
end
|
||||
|
||||
ForestAtmos.setting = onAndroid()
|
||||
and ModSetting.new("atmos", "FOREST FX", { "low", "off" },
|
||||
{ "LOW", "OFF" })
|
||||
or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" },
|
||||
{ "FULL", "LOW", "OFF" })
|
||||
|
||||
-- the animation clock: ticked by main.lua's always-running update hook,
|
||||
-- pinnable (frozen = true) so a screenshot driver can hold a frame still
|
||||
ForestAtmos.time = 0
|
||||
ForestAtmos.frozen = false
|
||||
|
||||
function ForestAtmos.update(dt)
|
||||
if ForestAtmos.frozen then return end
|
||||
ForestAtmos.time = ForestAtmos.time + (dt or 0)
|
||||
end
|
||||
|
||||
-- ------- the authored table
|
||||
--
|
||||
-- Same shape as BattleArena's: the data file behind a pcall with a false
|
||||
-- sentinel, and an overrides table a tuning driver can stage a candidate
|
||||
-- through before anything is written down. `~= nil` on the override,
|
||||
-- because false is meaningful -- "this map has no atmosphere, whatever
|
||||
-- the file says".
|
||||
|
||||
local authored = nil
|
||||
local overrides = {}
|
||||
|
||||
local function configFor(mapId)
|
||||
if not mapId then return nil end
|
||||
local forced = overrides[mapId]
|
||||
if forced ~= nil then return forced or nil end
|
||||
if authored == nil then
|
||||
local ok, list = pcall(V.data, "map_atmosphere")
|
||||
authored = (ok and type(list) == "table") and list or false
|
||||
end
|
||||
if not authored then return nil end
|
||||
return authored[mapId]
|
||||
end
|
||||
|
||||
ForestAtmos.configFor = configFor
|
||||
|
||||
-- ------- caches
|
||||
--
|
||||
-- Particle layouts and meshes go stale with the map (map.reloaded, and
|
||||
-- the pipeline's invalidate); called with no map id this also resets the
|
||||
-- shader and texture sentinels, which is what a lost GL context needs.
|
||||
|
||||
local layoutCache = {}
|
||||
local meshCache = {}
|
||||
local shaders = {} -- keyed by variant; nil untried, false refused
|
||||
local leafTex = nil -- the tiling leaf-dapple field
|
||||
local rayMesh = nil -- the fullscreen ray-fan quad, re-aimed per draw
|
||||
|
||||
-- Every bail here is deliberate and silent on screen -- a missing piece
|
||||
-- subtracts itself, never the frame -- but "the beams are off" and "the
|
||||
-- beams are off BECAUSE ..." are different debugging days. Each reason
|
||||
-- is said once on the console, the way VR reports a missing runtime.
|
||||
local said = {}
|
||||
local function say(key, msg)
|
||||
if said[key] then return end
|
||||
said[key] = true
|
||||
print("[DRAMATIC_SHAPE] atmos: " .. msg)
|
||||
end
|
||||
|
||||
function ForestAtmos.invalidate(mapId)
|
||||
if mapId then
|
||||
layoutCache[mapId] = nil
|
||||
meshCache[mapId] = nil
|
||||
else
|
||||
layoutCache, meshCache = {}, {}
|
||||
shaders = {}
|
||||
leafTex = nil
|
||||
rayMesh = nil
|
||||
end
|
||||
end
|
||||
|
||||
function ForestAtmos.setOverride(mapId, entry)
|
||||
overrides[mapId] = entry
|
||||
ForestAtmos.invalidate(mapId)
|
||||
end
|
||||
|
||||
-- ------- the hour's answer
|
||||
--
|
||||
-- One ramp, authored per phase and blended with DayNight's own weights,
|
||||
-- so the fog and the rays can never disagree about what hour it is. The
|
||||
-- two interact three ways: the fog colour leans toward the ray colour
|
||||
-- (noon warms the haze, midnight silvers it), the rays scale with the
|
||||
-- fog's density (a beam IS lit fog -- less medium, less beam), and the
|
||||
-- march accumulates through the same density the surfaces sink into.
|
||||
|
||||
ForestAtmos.RAMP = {
|
||||
day = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 },
|
||||
alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 },
|
||||
golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 },
|
||||
alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 },
|
||||
dawn = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 },
|
||||
alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 },
|
||||
dusk = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 },
|
||||
alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 },
|
||||
violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 },
|
||||
alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 },
|
||||
night = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 },
|
||||
alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
|
||||
}
|
||||
|
||||
-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
|
||||
-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
|
||||
-- and Voxel3D.fog should be left nil.
|
||||
function ForestAtmos.frame(map, t)
|
||||
if ForestAtmos.setting:get() == "off" then return nil end
|
||||
local cfg = configFor(map and map.id)
|
||||
if not cfg then return nil end
|
||||
local mix = DayNight.mix(t or DayNight.time())
|
||||
local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
|
||||
local alpha, dens, motes, flies = 0, 0, 0, 0
|
||||
for name, w in pairs(mix) do
|
||||
local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
|
||||
fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
|
||||
rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w
|
||||
alpha = alpha + p.alpha * w
|
||||
dens = dens + p.density * w
|
||||
motes = motes + p.motes * w
|
||||
flies = flies + p.flies * w
|
||||
end
|
||||
-- the haze takes on a little of the light standing in it
|
||||
local LEAN = 0.15
|
||||
fr = fr + (rr - fr) * LEAN
|
||||
fg = fg + (rg - fg) * LEAN
|
||||
fb = fb + (rb - fb) * LEAN
|
||||
local base = cfg.fog or {}
|
||||
local rays = cfg.rays or {}
|
||||
return {
|
||||
-- in exactly the shape Voxel3D.fog takes, so callers assign it whole
|
||||
fog = { color = { fr, fg, fb },
|
||||
density = (base.density or 0) * dens,
|
||||
start = base.start or 0,
|
||||
heightK = base.heightK or 0 },
|
||||
rayColor = { rr, rg, rb },
|
||||
-- a beam is scattered fog: less medium, less beam
|
||||
rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)),
|
||||
rayStrength = rays.strength or 12,
|
||||
rayReach = rays.reach or 380,
|
||||
moteLevel = motes,
|
||||
fireflyLevel = flies,
|
||||
cfg = cfg,
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- deterministic noise
|
||||
--
|
||||
-- The same written-out xorshift StadiumFx runs (see the note there on why
|
||||
-- not LuaJIT's `bit`): the particle deal and the leaf field must come out
|
||||
-- identical on every machine and every visit.
|
||||
|
||||
local function bxor32(a, b)
|
||||
local r, p = 0, 1
|
||||
for _ = 1, 32 do
|
||||
local x, y = a % 2, b % 2
|
||||
if x ~= y then r = r + p end
|
||||
a, b, p = floor(a / 2), floor(b / 2), p * 2
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
local Rng = {}
|
||||
Rng.__index = Rng
|
||||
|
||||
local function newRng(seed)
|
||||
local s = seed % 0x100000000
|
||||
if s == 0 then s = 0x9E3779B9 end
|
||||
return setmetatable({ s = s }, Rng)
|
||||
end
|
||||
|
||||
function Rng:next()
|
||||
local x = self.s
|
||||
x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13)
|
||||
x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17
|
||||
x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5)
|
||||
self.s = x % 0x100000000
|
||||
return self.s
|
||||
end
|
||||
|
||||
function Rng:unit()
|
||||
return self:next() / 0x100000000
|
||||
end
|
||||
|
||||
-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles
|
||||
local function lattice(rng, w, h)
|
||||
local g = {}
|
||||
for y = 1, h do
|
||||
local row = {}
|
||||
for x = 1, w do row[x] = rng:unit() end
|
||||
g[y] = row
|
||||
end
|
||||
return g
|
||||
end
|
||||
|
||||
local function smoothstep01(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
local function torus(grid, w, h, x, y)
|
||||
local x0, y0 = floor(x), floor(y)
|
||||
local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0)
|
||||
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
|
||||
x0, y0 = x0 % w, y0 % h
|
||||
local a = grid[y0 + 1][x0 + 1]
|
||||
local b = grid[y0 + 1][x1 + 1]
|
||||
local c = grid[y1 + 1][x0 + 1]
|
||||
local d = grid[y1 + 1][x1 + 1]
|
||||
return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy
|
||||
end
|
||||
|
||||
-- ------- the leaf field
|
||||
--
|
||||
-- One small tiling texture of three-octave value noise, generated once
|
||||
-- from a fixed seed: the pattern of the unseen foliage. The shader reads
|
||||
-- it at two drifting, differently-scaled offsets and thresholds the sum,
|
||||
-- so the pools of light between the leaves slide, open and close -- the
|
||||
-- movement is the WIND's, all in the sampling; the cloth itself never
|
||||
-- changes, which is what keeps a pinned frame reproducible.
|
||||
|
||||
local function leafTexture()
|
||||
if leafTex ~= nil then return leafTex or nil end
|
||||
if not (love and love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then
|
||||
leafTex = false
|
||||
return nil
|
||||
end
|
||||
local ok, tex = pcall(function()
|
||||
local N = 128
|
||||
local rng = newRng(0x1EAF)
|
||||
local g1 = lattice(rng, 8, 8)
|
||||
local g2 = lattice(rng, 16, 16)
|
||||
local g3 = lattice(rng, 32, 32)
|
||||
local img = love.image.newImageData(N, N)
|
||||
for y = 0, N - 1 do
|
||||
local v = y / N
|
||||
for x = 0, N - 1 do
|
||||
local u = x / N
|
||||
local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5
|
||||
+ torus(g2, 16, 16, u * 16, v * 16) * 0.3
|
||||
+ torus(g3, 32, 32, u * 32, v * 32) * 0.2
|
||||
img:setPixel(x, y, n, n, n, 1)
|
||||
end
|
||||
end
|
||||
local t = love.graphics.newImage(img)
|
||||
t:setWrap("repeat", "repeat")
|
||||
t:setFilter("linear", "linear")
|
||||
return t
|
||||
end)
|
||||
leafTex = (ok and tex) or false
|
||||
return leafTex or nil
|
||||
end
|
||||
|
||||
-- ------- placement (the particles; the light places itself)
|
||||
|
||||
local MARGIN = 24 -- keep off the map's edge, world px
|
||||
|
||||
function ForestAtmos.layout(cfg, w, h)
|
||||
local rng = newRng((cfg.seed or 0x51D))
|
||||
local canopy = cfg.canopyY or 56
|
||||
local motes = {}
|
||||
for _ = 1, (cfg.motes and cfg.motes.count) or 0 do
|
||||
motes[#motes + 1] = {
|
||||
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
|
||||
y = 3 + rng:unit() * max(canopy - 11, 8),
|
||||
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
|
||||
phase = rng:unit() * 6.2832,
|
||||
rate = 0.5 + rng:unit(),
|
||||
}
|
||||
end
|
||||
local flies = {}
|
||||
for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do
|
||||
flies[#flies + 1] = {
|
||||
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
|
||||
y = 3 + rng:unit() * 12,
|
||||
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
|
||||
phase = rng:unit() * 6.2832,
|
||||
rate = 0.5 + rng:unit(),
|
||||
}
|
||||
end
|
||||
return { motes = motes, flies = flies }
|
||||
end
|
||||
|
||||
-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
|
||||
-- for world pixels (the same arithmetic Structures runs in tiles).
|
||||
local function layoutFor(map)
|
||||
local hit = layoutCache[map.id]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local cfg = configFor(map.id)
|
||||
if not cfg then
|
||||
layoutCache[map.id] = false
|
||||
return nil
|
||||
end
|
||||
local def = map.def or {}
|
||||
local L = ForestAtmos.layout(cfg, (def.width or 16) * 32,
|
||||
(def.height or 16) * 32)
|
||||
layoutCache[map.id] = L
|
||||
return L
|
||||
end
|
||||
|
||||
ForestAtmos.layoutFor = layoutFor
|
||||
|
||||
-- ------- the volumetric march
|
||||
--
|
||||
-- A fullscreen quad whose four corners carry the camera's own frustum
|
||||
-- rays; the varying interpolates them into a world ray per pixel. The
|
||||
-- pixel stage walks that ray to the depth buffer's surface, and every
|
||||
-- step of air on the way is lit or not by the shadow map and the leaf
|
||||
-- field, accumulated through the same haze the surfaces sink into.
|
||||
--
|
||||
-- Conventions copied from Water's march: the ray walks the FLAT world
|
||||
-- (the space it is straight in) and every depth compare bends the sample
|
||||
-- first, by the same displacement the vertex stage applies -- so the
|
||||
-- march reads the depth buffer it actually has. The shadow lookup stays
|
||||
-- flat, exactly like the scene shader's own vSun. STEPS is spliced into
|
||||
-- the source rather than sent (the LOW rung is a second compile), and
|
||||
-- there are no uniform arrays anywhere -- see the note in Sky about the
|
||||
-- Android driver that reads them as zero.
|
||||
|
||||
local RAY_SHADER = [[
|
||||
varying vec3 vRay;
|
||||
#ifdef VERTEX
|
||||
attribute vec3 RayDir;
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
vRay = RayDir;
|
||||
return transform_projection * vertex_position;
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
uniform Image depthTex; // the frame's own depth, detached to read
|
||||
uniform Image sunMap; // the sun's answer (see ShadowMap)
|
||||
uniform Image leafTex; // the unseen foliage, tiling
|
||||
uniform mat4 vp;
|
||||
uniform mat4 sunVP;
|
||||
uniform float sunBias;
|
||||
uniform vec3 eye;
|
||||
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
|
||||
uniform vec2 screen; // canvas size, for the pixel's own uv
|
||||
uniform vec4 fogW; // density, heightK, canopyY, fadeTo
|
||||
uniform vec3 shear; // the noon shear kx, kz; z = reach
|
||||
uniform vec3 rayColor;
|
||||
uniform float strength;
|
||||
uniform vec3 sunward; // unit, toward the unseen sun
|
||||
uniform vec2 wind; // leaf-field drift, uv per second
|
||||
uniform float time;
|
||||
// the 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;
|
||||
uniform vec2 cullRect; // the box's half-extents in x and z
|
||||
|
||||
float dioramaCull(vec3 p) {
|
||||
if (cullShape.z <= 0.5) return 1.0;
|
||||
vec3 cd = p - cullAt;
|
||||
float inside;
|
||||
if (cullShape.z < 1.5) { // the box
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd); // the ball
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz); // the fight's pillar
|
||||
}
|
||||
return clamp(inside * cullShape.y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float sunDepth(vec2 uv) {
|
||||
vec4 c = Texel(sunMap, uv);
|
||||
return c.r + c.g * (1.0 / 255.0);
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec2 uv = sc / screen;
|
||||
float sceneD = Texel(depthTex, uv).r;
|
||||
vec3 dir = normalize(vRay);
|
||||
// Spend every sample where a sample can glow. Above the canopy no
|
||||
// beam exists, and below the floor there is no air at all -- so the
|
||||
// march runs from where this ray first dips under the leaves to
|
||||
// where it would pass the ground, however long or short that
|
||||
// stretch is. From the orbit camera that is the last few dozen
|
||||
// pixels of a mostly-vertical ray, and dividing the WHOLE reach by
|
||||
// the step count there starved the beams to nothing.
|
||||
float t0 = 0.0;
|
||||
if (eye.y > fogW.z) {
|
||||
if (dir.y >= -0.01) return vec4(0.0);
|
||||
t0 = (eye.y - fogW.z) / -dir.y;
|
||||
}
|
||||
float tEnd = shear.z;
|
||||
if (dir.y < -0.01) {
|
||||
tEnd = min(tEnd, (eye.y + 8.0) / -dir.y);
|
||||
}
|
||||
if (tEnd <= t0) return vec4(0.0);
|
||||
// interleaved gradient noise staggers neighbouring pixels' steps,
|
||||
// which is what turns 20-odd samples into a smooth volume instead
|
||||
// of an onion of banded slices
|
||||
float jitter = fract(52.9829189
|
||||
* fract(dot(sc, vec2(0.06711056, 0.00583715))));
|
||||
float dt = (tEnd - t0) / float(STEPS);
|
||||
// HALF the fog's own extinction, on the way in and per step: the
|
||||
// full rate is what the surfaces sink by, and beams that obeyed it
|
||||
// too died before the orbit camera ever saw them. Half keeps the
|
||||
// depth cue and leaves the light alive.
|
||||
float trans = exp(-fogW.x * 0.5 * t0);
|
||||
float acc = 0.0;
|
||||
for (int i = 0; i < STEPS; i++) {
|
||||
float t = t0 + (float(i) + jitter) * dt;
|
||||
vec3 p = eye + dir * t;
|
||||
// stop at the surface: bend the sample the way the geometry bent
|
||||
vec2 cd = p.xz - curve.xy;
|
||||
vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0);
|
||||
if (c.w <= 1e-6) break;
|
||||
if (c.z / c.w * 0.5 + 0.5 > sceneD) break;
|
||||
if (p.y < fogW.z) {
|
||||
// the sun's question: is this air behind a tree? Outside the
|
||||
// frustum nothing was recorded and the air counts as lit, eased
|
||||
// at the rim exactly like the scene shader's shadows
|
||||
float lit = 1.0;
|
||||
vec3 su = (sunVP * vec4(p, 1.0)).xyz;
|
||||
if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0
|
||||
&& su.z < 1.0) {
|
||||
vec2 e2 = min(su.xy, 1.0 - su.xy);
|
||||
float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y));
|
||||
lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge);
|
||||
}
|
||||
// the canopy's question: where did this thread of light pierce
|
||||
// the leaves? Every step of air on one sun ray shares the
|
||||
// answer -- that shared point is what makes a shaft a shaft --
|
||||
// and the two drifting reads of the field are the wind moving
|
||||
// the foliage overhead, opening and closing the beams
|
||||
float up = fogW.z - p.y;
|
||||
vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0);
|
||||
float n = Texel(leafTex, gap + wind * time).r * 0.65
|
||||
+ Texel(leafTex, gap * 2.3 - wind * (time * 0.7)
|
||||
+ vec2(0.37, 0.61)).r * 0.35;
|
||||
float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n);
|
||||
// the beam fades IN below the invisible canopy, thins with
|
||||
// altitude like the haze it is made of, and kisses the floor
|
||||
float y = max(p.y, 0.0);
|
||||
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
|
||||
* dioramaCull(p);
|
||||
}
|
||||
trans *= exp(-fogW.x * 0.5 * dt);
|
||||
}
|
||||
// forward scattering: beams bloom for a camera looking up into the
|
||||
// light, which is most of what makes them read as light IN air
|
||||
float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0);
|
||||
return vec4(rayColor * (acc * strength * phase), 1.0) * color;
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
local function rayShaderFor(steps)
|
||||
local key = "ray" .. steps
|
||||
local s = shaders[key]
|
||||
if s ~= nil then return s or nil end
|
||||
if not (love and love.graphics and love.graphics.newShader) then
|
||||
shaders[key] = false
|
||||
return nil
|
||||
end
|
||||
local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER
|
||||
local ok, sh = pcall(love.graphics.newShader, src)
|
||||
if not ok then
|
||||
say(key, "ray shader refused -- beams off, fog stays: "
|
||||
.. tostring(sh))
|
||||
end
|
||||
shaders[key] = (ok and sh) or false
|
||||
return shaders[key] or nil
|
||||
end
|
||||
|
||||
local RAY_FORMAT = {
|
||||
{ "VertexPosition", "float", 2 },
|
||||
{ "RayDir", "float", 3 },
|
||||
}
|
||||
|
||||
-- The camera's frustum corners, from the same fields every pass sets:
|
||||
-- eye, focus, fovY, and the placed camera's up when there is one (VR
|
||||
-- eyes roll; the orbit never does). Interpolating a corner ray across
|
||||
-- the quad IS the standard reconstruction for a perspective camera, so
|
||||
-- this works identically for the orbit, first person and both eyes.
|
||||
local function rayQuad(Voxel3D, w, h)
|
||||
local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY
|
||||
if not (e and fo and fov) then return nil end
|
||||
local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
|
||||
local fl = sqrt(fx * fx + fy * fy + fz * fz)
|
||||
if fl < 1e-6 then return nil end
|
||||
fx, fy, fz = fx / fl, fy / fl, fz / fl
|
||||
local cam = Voxel3D.camera
|
||||
local up = (cam and cam.up) or { 0, 1, 0 }
|
||||
-- right = forward x up, then a true up perpendicular to both
|
||||
local rx = fy * up[3] - fz * up[2]
|
||||
local ry = fz * up[1] - fx * up[3]
|
||||
local rz = fx * up[2] - fy * up[1]
|
||||
local rl = sqrt(rx * rx + ry * ry + rz * rz)
|
||||
if rl < 1e-6 then return nil end
|
||||
rx, ry, rz = rx / rl, ry / rl, rz / rl
|
||||
local ux = ry * fz - rz * fy
|
||||
local uy = rz * fx - rx * fz
|
||||
local uz = rx * fy - ry * fx
|
||||
local hh = math.tan(fov * 0.5)
|
||||
local hw = hh * (w / h)
|
||||
-- canvas row 0 is the TOP of the frame, which is the +up corner
|
||||
local function corner(su, sv)
|
||||
return fx + rx * hw * su + ux * hh * sv,
|
||||
fy + ry * hw * su + uy * hh * sv,
|
||||
fz + rz * hw * su + uz * hh * sv
|
||||
end
|
||||
local x0, y0, z0 = corner(-1, 1)
|
||||
local x1, y1, z1 = corner(1, 1)
|
||||
local x2, y2, z2 = corner(1, -1)
|
||||
local x3, y3, z3 = corner(-1, -1)
|
||||
local verts = {
|
||||
{ 0, 0, x0, y0, z0 },
|
||||
{ w, 0, x1, y1, z1 },
|
||||
{ w, h, x2, y2, z2 },
|
||||
{ 0, h, x3, y3, z3 },
|
||||
}
|
||||
if not rayMesh then
|
||||
local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts,
|
||||
"fan", "stream")
|
||||
rayMesh = ok and mesh or nil
|
||||
return rayMesh
|
||||
end
|
||||
local ok = pcall(rayMesh.setVertices, rayMesh, verts)
|
||||
return ok and rayMesh or nil
|
||||
end
|
||||
|
||||
-- ------- the particles
|
||||
|
||||
local PART_SHADER = [[
|
||||
varying vec2 vCorner;
|
||||
varying float vGlow;
|
||||
#ifdef VERTEX
|
||||
uniform mat4 vp;
|
||||
uniform vec3 curve;
|
||||
uniform vec3 cullAt; // the viewport (see Voxel3D.cull): a mote
|
||||
uniform vec3 cullShape; // outside the model is not in the air
|
||||
uniform vec2 cullRect; // the box's half-extents in x and z
|
||||
uniform vec3 axisR; // the camera's right, world space
|
||||
uniform vec3 axisU; // and its up: the billboard's own frame
|
||||
uniform float time;
|
||||
uniform float size;
|
||||
uniform vec2 sway; // wander amplitude: horizontal, vertical
|
||||
uniform float blinky; // 0 = steady motes, 1 = blinking fireflies
|
||||
attribute vec4 AtmosData; // corner x, corner y, phase, rate
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
float ph = AtmosData.z;
|
||||
float rt = AtmosData.w;
|
||||
float t = time * (0.5 + rt);
|
||||
// bounded wander only -- three incommensurate sines, so nothing ever
|
||||
// walks off the map or needs a CPU tick to bring it home
|
||||
vec3 base = vertex_position.xyz + vec3(
|
||||
sin(t * 0.23 + ph) * sway.x,
|
||||
sin(t * 0.17 + ph * 2.7) * sway.y,
|
||||
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 inside;
|
||||
if (cullShape.z < 1.5) {
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd);
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz);
|
||||
}
|
||||
vGlow *= clamp(inside * cullShape.y, 0.0, 1.0);
|
||||
}
|
||||
vCorner = AtmosData.xy;
|
||||
vec4 w = vec4(base + axisR * (AtmosData.x * size)
|
||||
+ axisU * (AtmosData.y * size), 1.0);
|
||||
if (curve.z > 0.0) {
|
||||
vec2 cd = w.xz - curve.xy;
|
||||
w.y -= dot(cd, cd) * curve.z;
|
||||
}
|
||||
return vp * w;
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
uniform vec3 dotColor;
|
||||
uniform float level;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float d = dot(vCorner, vCorner);
|
||||
float glow = max(0.0, 1.0 - d);
|
||||
glow *= glow;
|
||||
return vec4(dotColor, glow * level * vGlow) * color;
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
local function partShader()
|
||||
local s = shaders.part
|
||||
if s ~= nil then return s or nil end
|
||||
if not (love and love.graphics and love.graphics.newShader) then
|
||||
shaders.part = false
|
||||
return nil
|
||||
end
|
||||
local ok, sh = pcall(love.graphics.newShader, PART_SHADER)
|
||||
shaders.part = (ok and sh) or false
|
||||
return shaders.part or nil
|
||||
end
|
||||
|
||||
local PART_FORMAT = {
|
||||
{ "VertexPosition", "float", 3 },
|
||||
{ "AtmosData", "float", 4 },
|
||||
}
|
||||
|
||||
local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } }
|
||||
|
||||
local function pushQuad(map, n)
|
||||
local b = n * 4
|
||||
map[#map + 1] = b + 1
|
||||
map[#map + 1] = b + 2
|
||||
map[#map + 1] = b + 3
|
||||
map[#map + 1] = b + 1
|
||||
map[#map + 1] = b + 3
|
||||
map[#map + 1] = b + 4
|
||||
end
|
||||
|
||||
local function buildPartMesh(points)
|
||||
if #points == 0 then return nil end
|
||||
local verts, indices = {}, {}
|
||||
for i = 1, #points do
|
||||
local p = points[i]
|
||||
for c = 1, 4 do
|
||||
verts[#verts + 1] = { p.x, p.y, p.z,
|
||||
CORNERS[c][1], CORNERS[c][2], p.phase, p.rate }
|
||||
end
|
||||
pushQuad(indices, i - 1)
|
||||
end
|
||||
local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts,
|
||||
"triangles", "static")
|
||||
if not ok then return nil end
|
||||
pcall(mesh.setVertexMap, mesh, indices)
|
||||
return mesh
|
||||
end
|
||||
|
||||
local function meshesFor(map, L)
|
||||
local hit = meshCache[map.id]
|
||||
if hit then return hit end
|
||||
local M = {
|
||||
motes = buildPartMesh(L.motes),
|
||||
flies = buildPartMesh(L.flies),
|
||||
}
|
||||
meshCache[map.id] = M
|
||||
return M
|
||||
end
|
||||
|
||||
local MOTE_COLOR = { 1.0, 0.96, 0.78 }
|
||||
local FLY_COLOR = { 0.72, 1.0, 0.45 }
|
||||
|
||||
-- The billboard frame: the camera's own right and up, from the same
|
||||
-- fields every pass sets (per VR eye too -- drawScene runs per eye and
|
||||
-- reads the eye's camera). Degenerate looks answer nil and the
|
||||
-- particles sit this one out.
|
||||
local function billboardAxes(Voxel3D)
|
||||
local e, fo = Voxel3D.eye, Voxel3D.focus
|
||||
if not (e and fo) then return nil end
|
||||
local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
|
||||
local ll = sqrt(lx * lx + ly * ly + lz * lz)
|
||||
if ll < 1e-6 then return nil end
|
||||
lx, ly, lz = lx / ll, ly / ll, lz / ll
|
||||
local rx, rz = -lz, lx
|
||||
local rl = sqrt(rx * rx + rz * rz)
|
||||
if rl < 1e-4 then return nil end
|
||||
rx, rz = rx / rl, rz / rl
|
||||
return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly }
|
||||
end
|
||||
|
||||
-- ------- the draw
|
||||
--
|
||||
-- Inside the scene pass, in VoxelScene's prop slot. The march borrows
|
||||
-- the frame's depth through Voxel3D.beginWater -- the same detach Water
|
||||
-- runs -- and hand-tests every step against it, so the pass itself needs
|
||||
-- no depth attachment; the particles come after, depth-tested additive
|
||||
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
|
||||
-- refused shader, no readable depth, no shadow map -- subtracts only
|
||||
-- itself.
|
||||
function ForestAtmos.draw(map)
|
||||
local rung = ForestAtmos.setting:get()
|
||||
if rung == "off" then return end
|
||||
local f = ForestAtmos.frame(map)
|
||||
if not f then return end
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
if f.rayAlpha > 0.01 then
|
||||
if not Voxel3D.depthReadable() then
|
||||
say("depth", "no readable depth this frame -- beams off, fog stays")
|
||||
return
|
||||
end
|
||||
-- no beams without the sun's own pass: uvVP is only the world -> map
|
||||
-- transform while a shadow map is actually standing
|
||||
local sunTex = ShadowMap.active() and ShadowMap.texture()
|
||||
if not sunTex then
|
||||
say("sun", "no shadow map standing -- beams off, fog stays")
|
||||
end
|
||||
local leaf = leafTexture()
|
||||
if not leaf then
|
||||
say("leaf", "leaf field would not build -- beams off, fog stays")
|
||||
end
|
||||
local sh = rayShaderFor(rung == "low" and 12 or 24)
|
||||
local w, h = Voxel3D.size()
|
||||
local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h)
|
||||
if sh and sunTex and leaf and w and not quad then
|
||||
say("quad", "no camera frame for the ray fan -- beams off")
|
||||
end
|
||||
if quad then
|
||||
local _, depth = Voxel3D.beginWater(nil)
|
||||
if depth then
|
||||
say("on", "volumetric beams running")
|
||||
local kx, kz = DayNight.shearAt(DayNight.T.day)
|
||||
local kl = sqrt(kx * kx + kz * kz + 1)
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "depthTex", depth)
|
||||
pcall(sh.send, sh, "sunMap", sunTex)
|
||||
pcall(sh.send, sh, "leafTex", leaf)
|
||||
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
|
||||
pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP)
|
||||
pcall(sh.send, sh, "sunBias", ShadowMap.bias)
|
||||
pcall(sh.send, sh, "eye", Voxel3D.eye)
|
||||
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, "cullRect", cullRect())
|
||||
pcall(sh.send, sh, "screen", { w, h })
|
||||
pcall(sh.send, sh, "fogW",
|
||||
{ f.fog.density, f.fog.heightK,
|
||||
f.cfg.canopyY or 56, f.cfg.fadeTo or 28 })
|
||||
pcall(sh.send, sh, "shear", { kx, kz, f.rayReach })
|
||||
pcall(sh.send, sh, "rayColor", f.rayColor)
|
||||
pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha)
|
||||
pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl })
|
||||
pcall(sh.send, sh, "wind", { 0.016, 0.009 })
|
||||
pcall(sh.send, sh, "time", ForestAtmos.time)
|
||||
pcall(love.graphics.draw, quad)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode("alpha")
|
||||
end
|
||||
Voxel3D.endWater()
|
||||
end
|
||||
end
|
||||
|
||||
if rung == "full" then
|
||||
local L = layoutFor(map)
|
||||
local M = L and meshesFor(map, L)
|
||||
local psh = partShader()
|
||||
local axisR, axisU = billboardAxes(Voxel3D)
|
||||
if M and psh and axisR then
|
||||
Voxel3D.blend("add")
|
||||
if Voxel3D.beginEffect(psh) then
|
||||
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
|
||||
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, "cullRect", cullRect())
|
||||
pcall(psh.send, psh, "axisR", axisR)
|
||||
pcall(psh.send, psh, "axisU", axisU)
|
||||
pcall(psh.send, psh, "time", ForestAtmos.time)
|
||||
if M.motes and f.moteLevel > 0.02 then
|
||||
pcall(psh.send, psh, "size", 1.4)
|
||||
pcall(psh.send, psh, "sway", { 5, 2.5 })
|
||||
pcall(psh.send, psh, "blinky", 0)
|
||||
pcall(psh.send, psh, "dotColor", MOTE_COLOR)
|
||||
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
|
||||
pcall(love.graphics.draw, M.motes)
|
||||
end
|
||||
if M.flies and f.fireflyLevel > 0.02 then
|
||||
pcall(psh.send, psh, "size", 1.6)
|
||||
pcall(psh.send, psh, "sway", { 10, 4 })
|
||||
pcall(psh.send, psh, "blinky", 1)
|
||||
pcall(psh.send, psh, "dotColor", FLY_COLOR)
|
||||
pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
|
||||
pcall(love.graphics.draw, M.flies)
|
||||
end
|
||||
Voxel3D.endEffect()
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return ForestAtmos
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
-- LET'S GO: the row, the modes, and every engine seam the capture game
|
||||
-- stands on.
|
||||
--
|
||||
-- Three rungs:
|
||||
--
|
||||
-- OFF nothing changes. The default, and what an unrecognised
|
||||
-- stored value falls back to.
|
||||
-- FULL the whole Let's Go treatment. A wild encounter opens
|
||||
-- STRAIGHT into capture mode (B backs out to the classic
|
||||
-- menu for anyone who came to fight), Poke/Great/Ultra
|
||||
-- Balls are half price at every mart, and EXPERIENCE works
|
||||
-- the way that game's does: every healthy party member
|
||||
-- gains from every catch AND every trainer knockout, each
|
||||
-- measured against its own level. A catch adds the throw
|
||||
-- stack on top -- grade, first throw, new species, combo.
|
||||
-- CATCH ONLY the fights are untouched and the shops are untouched;
|
||||
-- the one change is that throwing a ball -- from the bag,
|
||||
-- or a SAFARI BALL from the safari menu -- runs the throw
|
||||
-- minigame instead of the automatic toss. The minigame's
|
||||
-- grade still folds into the Gen 1 catch roll (a good
|
||||
-- throw should matter or the ring is a lie), but nothing
|
||||
-- outside the throw changes.
|
||||
--
|
||||
-- The capture game itself lives in lib/CatchThrow.lua and the ball it
|
||||
-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
|
||||
-- the two BattleState wraps that intercept a ball being thrown, the
|
||||
-- auto-entry tick for FULL, the price patch, and the experience hooks.
|
||||
--
|
||||
-- ------- where the minigame declines to run
|
||||
--
|
||||
-- The throw is a 3D scene: it needs the staged battle standing (the
|
||||
-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
|
||||
-- default), a driver with a depth buffer, and a flat screen (the VR seat
|
||||
-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
|
||||
-- switched off, a headless driver, a headset -- the ball quietly takes
|
||||
-- the engine's own toss, which is exactly what the mod's "declines
|
||||
-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
|
||||
-- old man's demo keep the vanilla path on purpose: those branches ARE
|
||||
-- their behaviour.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local CatchThrow = V.require("CatchThrow")
|
||||
|
||||
local LetsGo = {}
|
||||
|
||||
LetsGo.KEY = "letsgo"
|
||||
LetsGo.LABEL = "LET'S GO"
|
||||
|
||||
-- `false` first: the default, and the fallback for a stored value from
|
||||
-- some other version of this ladder
|
||||
LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
|
||||
{ false, "full", "catching" },
|
||||
{ "OFF", "FULL", "CATCH ONLY" })
|
||||
|
||||
-- false | "full" | "catching"
|
||||
function LetsGo.mode()
|
||||
return LetsGo.setting:get()
|
||||
end
|
||||
|
||||
local function game() return require("src.core.Game") end
|
||||
|
||||
-- ------- half-price balls (FULL)
|
||||
--
|
||||
-- Prices are live data (game.data.items[id].price) and every reader --
|
||||
-- the buy list, the affordability check, the quantity box -- reads them
|
||||
-- per use, so patching the table IS the feature. Applied and reverted on
|
||||
-- the option's edge, polled from the tick because the row, the manager's
|
||||
-- page and a loaded save can all move it and none of them announces to
|
||||
-- us. The sell price follows automatically (the mart pays half of list),
|
||||
-- which is coherent: cheaper balls are worth less back too.
|
||||
local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
|
||||
local fullPrices = nil -- originals while halved, or nil
|
||||
|
||||
local function applyPrices()
|
||||
local g = game()
|
||||
local items = g and g.data and g.data.items
|
||||
if not items then return end
|
||||
local wantHalf = LetsGo.mode() == "full"
|
||||
if wantHalf and not fullPrices then
|
||||
fullPrices = {}
|
||||
for _, id in ipairs(PRICED) do
|
||||
local def = items[id]
|
||||
if def and def.price then
|
||||
fullPrices[id] = def.price
|
||||
def.price = math.floor(def.price / 2)
|
||||
end
|
||||
end
|
||||
elseif not wantHalf and fullPrices then
|
||||
for id, price in pairs(fullPrices) do
|
||||
if items[id] then items[id].price = price end
|
||||
end
|
||||
fullPrices = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- whether a throw can be the minigame
|
||||
|
||||
local function vrOn()
|
||||
local ok, vr = pcall(V.require, "VR")
|
||||
return ok and vr and vr.enabled and vr.enabled() or false
|
||||
end
|
||||
|
||||
function LetsGo.wantsMinigame(battle)
|
||||
if not LetsGo.mode() then return false end
|
||||
if not battle or battle.kind ~= "wild" then return false end
|
||||
if battle.demo or battle.ghost or battle.noCatch then return false end
|
||||
if not Voxel3D.available() or vrOn() then return false end
|
||||
-- the staged shot must actually be standing: this is "there is a 3D
|
||||
-- battle on screen right now", which the throw is aimed into
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot) and true or false
|
||||
end
|
||||
|
||||
-- A Let's Go wild: the encounters FULL owns outright. In these the foe
|
||||
-- never takes a turn, the player's Pokemon is never sent out or shown,
|
||||
-- B runs (and always escapes), and the encounter lives in throw mode
|
||||
-- from the wipe to the last message.
|
||||
function LetsGo.fullWild(battle)
|
||||
return LetsGo.mode() == "full" and battle and battle.kind == "wild"
|
||||
and not (battle.safari or battle.demo or battle.ghost
|
||||
or battle.noCatch)
|
||||
and true or false
|
||||
end
|
||||
|
||||
-- ------- the experience stack (FULL)
|
||||
--
|
||||
-- Let's Go pays a catch like a knockout, through the Gen VII scaled
|
||||
-- formula -- every party member paid against its OWN level -- times the
|
||||
-- catch bonuses. Three engine hooks carry it:
|
||||
--
|
||||
-- battle.catch_exp "does a catch pay at all" -- yes, under FULL
|
||||
-- battle.exp_award the distribution: every healthy party member its
|
||||
-- own full share, no participant split
|
||||
-- exp.gain the amount: the scaled formula times the bonus
|
||||
-- stack, in place of floor(b*L/7)
|
||||
--
|
||||
-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
|
||||
-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
|
||||
-- combo tier. Traded 1.5 still rides through the engine's own flag.
|
||||
local expCtx = nil -- {battle, mult} while a Let's Go catch pays out
|
||||
local granting = nil -- set across the applyShare loop for exp.gain
|
||||
|
||||
local function comboMult(n)
|
||||
if n <= 10 then return 1.1 end
|
||||
if n <= 20 then return 1.5 end
|
||||
if n <= 30 then return 2.0 end
|
||||
if n <= 40 then return 2.5 end
|
||||
return 3.0
|
||||
end
|
||||
|
||||
-- the catch combo, persisted with the save (mod.save rides save.modData):
|
||||
-- catching the same species again extends it, anything else restarts it
|
||||
local function bumpCombo(species)
|
||||
local ms = V.mod and V.mod.save
|
||||
local combo = { species = species, count = 1 }
|
||||
if ms then
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
if ok and type(held) == "table" and held.species == species then
|
||||
combo.count = (tonumber(held.count) or 0) + 1
|
||||
end
|
||||
pcall(ms.set, ms, "letsgoCombo", combo)
|
||||
end
|
||||
return combo.count
|
||||
end
|
||||
|
||||
function LetsGo.combo()
|
||||
local ms = V.mod and V.mod.save
|
||||
if not ms then return nil end
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
return ok and type(held) == "table" and held or nil
|
||||
end
|
||||
|
||||
-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
|
||||
-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
|
||||
-- still answerable, and the exp hooks fire inside storeCaughtMon.
|
||||
function LetsGo.noteCatch(battle, info)
|
||||
local species = battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.species
|
||||
local chain = species and bumpCombo(species) or 1
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
local mult = info.mult or 1
|
||||
if info.firstThrow then mult = mult * 1.5 end
|
||||
local dex = game().save and game().save.pokedex
|
||||
if dex and species and not dex.owned[species] then mult = mult * 1.1 end
|
||||
mult = mult * comboMult(chain)
|
||||
expCtx = { battle = battle, mult = mult }
|
||||
end
|
||||
|
||||
-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
|
||||
-- level, +1, then the traded boost and (for a catch) the bonus stack.
|
||||
-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
|
||||
-- full share rather than a share of one.
|
||||
--
|
||||
-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
|
||||
-- absent from the catch-side write-ups of this formula for the simple
|
||||
-- reason that a caught Pokemon is always wild, so it is always 1 there --
|
||||
-- which is also why adding it leaves every catch payout exactly where it
|
||||
-- was, verified against the published table in the suite.
|
||||
local function scaledGain(c, mult)
|
||||
local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
|
||||
local L = c.level or 1
|
||||
local Lp = (c.mon and c.mon.level) or L
|
||||
local a = c.isTrainer and 1.5 or 1
|
||||
local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
|
||||
local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
|
||||
if c.traded then exp = math.floor(exp * 1.5) end
|
||||
return math.max(1, math.floor(exp * (mult or 1)))
|
||||
end
|
||||
|
||||
LetsGo._scaledGain = scaledGain -- named for the suite
|
||||
LetsGo._comboMult = comboMult
|
||||
|
||||
-- ------- FULL's auto-entry
|
||||
--
|
||||
-- The moment a wild battle's menu opens under FULL, capture mode opens
|
||||
-- over it, with the last ball the player threw (or the first ball in the
|
||||
-- bag). B backs out to the classic menu and stays out for that battle --
|
||||
-- the bag's own ball route still re-enters the throw.
|
||||
local function autoEnter()
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
if CatchThrow.active() then return end
|
||||
local ok, battle = pcall(function()
|
||||
return V.require("OverworldBattle").battle()
|
||||
end)
|
||||
if not (ok and battle) then return end
|
||||
local g = game()
|
||||
if not (g.stack and g.stack:top() == battle) then return end
|
||||
if battle.phase ~= "menu" then return end
|
||||
if battle.safari then return end -- the safari menu is already a
|
||||
-- catch menu; its BALL row enters
|
||||
if battle.dramaticShapeDeclined then return end
|
||||
if not LetsGo.wantsMinigame(battle) then return end
|
||||
local ball = CatchThrow.pickBall()
|
||||
if not ball then return end
|
||||
CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
|
||||
fullWild = LetsGo.fullWild(battle) })
|
||||
end
|
||||
|
||||
-- ------- per frame, from the voxel pipeline's update hook
|
||||
--
|
||||
-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
|
||||
-- frame computes is the ball the scene render a moment later draws.
|
||||
function LetsGo.update(dt)
|
||||
applyPrices()
|
||||
CatchThrow.update(dt)
|
||||
autoEnter()
|
||||
end
|
||||
|
||||
-- ------- install: the two throw seams, the hooks, the input
|
||||
--
|
||||
-- Installed from main.lua AFTER every other input seam, so the capture's
|
||||
-- pointer wraps sit outside them all while it aims.
|
||||
local installed = false
|
||||
|
||||
function LetsGo.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local mod = V.mod
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
if not BattleState.dramaticShapeLetsGoHook then
|
||||
-- The bag's ball route: BagMenu has already consumed the ball and
|
||||
-- closed itself when this is called, so a session here owns a paid
|
||||
-- ball (cancel refunds it). Vanilla path untouched whenever the
|
||||
-- minigame cannot or should not run.
|
||||
local innerThrow = BattleState.throwBall
|
||||
function BattleState:throwBall(ball)
|
||||
if LetsGo.wantsMinigame(self) then
|
||||
if CatchThrow.begin(self, ball, {
|
||||
consumed = true, fullWild = LetsGo.fullWild(self),
|
||||
}) then return end
|
||||
end
|
||||
return innerThrow(self, ball)
|
||||
end
|
||||
|
||||
-- The safari menu's BALL row: same interception, safari flavour --
|
||||
-- the ball count and the flee check belong to the safari turn, and
|
||||
-- CatchThrow hands back to safariEnemyTurn on a failure.
|
||||
local innerSafari = BattleState.safariAction
|
||||
function BattleState:safariAction(choice)
|
||||
if choice == "ball" and LetsGo.wantsMinigame(self)
|
||||
and self.safari and self.safari.balls > 0 then
|
||||
if CatchThrow.begin(self, "SAFARI_BALL",
|
||||
{ consumed = false, safari = true }) then
|
||||
return
|
||||
end
|
||||
end
|
||||
return innerSafari(self, choice)
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeLetsGoHook = true
|
||||
end
|
||||
|
||||
-- a catch pays experience under FULL, exactly as a knockout would
|
||||
mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle
|
||||
and ctx.battle.kind == "wild" then
|
||||
return true
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- ------- the Let's Go distribution: every healthy party member, in full
|
||||
--
|
||||
-- The engine's own rule is that only the Pokemon that FOUGHT are paid,
|
||||
-- and they split one award between them; EXP.ALL exists to soften that.
|
||||
-- Let's Go deletes the whole arrangement -- everybody gains from
|
||||
-- everything, which is why that game ships no EXP.ALL at all -- and
|
||||
-- each one is measured against its OWN level, so the low member of a
|
||||
-- party pulls several times what the high one does from the same
|
||||
-- knockout.
|
||||
--
|
||||
-- Two ways in. A CATCH arrives with a bonus stack attached (throw
|
||||
-- grade, first ball, new species, combo) which `expCtx` carries. A
|
||||
-- KNOCKOUT under FULL takes the same distribution with no stack --
|
||||
-- those bonuses are rewards for the throw, and there was no throw.
|
||||
--
|
||||
-- Everything else -- CATCH ONLY, the row switched off, another mod's
|
||||
-- battle -- falls through to the engine's own split untouched.
|
||||
-- ------- and it is announced ONCE, not once per Pokemon
|
||||
--
|
||||
-- Six party members would otherwise mean six "X gained N EXP. Points!"
|
||||
-- boxes per knockout. The per-Pokemon lines are suppressed (the `false`
|
||||
-- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua
|
||||
-- for why that is better than a faster wall of the same text.
|
||||
--
|
||||
-- The card is queued BEFORE the loop that fills it. That is not a race:
|
||||
-- applyShare applies its experience immediately and only QUEUES its
|
||||
-- messages, so the loop runs to completion synchronously here, while
|
||||
-- the queue does not reach the card's factory until later -- by which
|
||||
-- time `rows` is complete. Queueing it first is what puts the tally
|
||||
-- ahead of the "grew to level" chatter it is a summary of.
|
||||
local ExpPanel = V.require("ExpPanel")
|
||||
local function payParty(ctx, mult)
|
||||
local battle = ctx.battle
|
||||
local rows = {}
|
||||
battle:uiNext(function() return ExpPanel.new(battle.game, rows) end)
|
||||
granting = { mult = mult }
|
||||
local okAward, err = pcall(function()
|
||||
for _, mon in ipairs(battle.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
local exp0, lv0 = mon.exp, mon.level
|
||||
ctx.applyShare(mon, 1, false)
|
||||
rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0,
|
||||
from = lv0, to = mon.level }
|
||||
end
|
||||
end
|
||||
end)
|
||||
granting = nil
|
||||
if not okAward then error(err, 0) end
|
||||
end
|
||||
|
||||
mod.hooks:wrap("battle.exp_award", function(next_, ctx)
|
||||
local cc = expCtx
|
||||
if cc and ctx and ctx.battle == cc.battle then
|
||||
expCtx = nil
|
||||
return payParty(ctx, cc.mult)
|
||||
end
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle then
|
||||
return payParty(ctx, 1)
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- and the amount, per receiving mon, while that loop runs
|
||||
mod.hooks:wrap("exp.gain", function(next_, c)
|
||||
if not granting then return next_(c) end
|
||||
return scaledGain(c, granting.mult)
|
||||
end)
|
||||
|
||||
-- ------- FULL owns a wild encounter from its first frame
|
||||
--
|
||||
-- The engine's intro ends by sending the player's Pokemon out -- the
|
||||
-- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
|
||||
-- Go wild has no player Pokemon in it at all. The send-out is exactly
|
||||
-- the LAST SIX rows of the intro queue when this event fires (built in
|
||||
-- BattleState's start, gated `not safari and not demo`), so they are
|
||||
-- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
|
||||
-- if a future engine moves them: the veil still hides the visuals, the
|
||||
-- engine just narrates a send-out that is not shown.
|
||||
--
|
||||
-- Stripping them leaves showPlayerBack TRUE for the whole battle, which
|
||||
-- is the flag the engine's own HUD path reads as "no player HUD" -- the
|
||||
-- player's side vanishes from the readout for free.
|
||||
--
|
||||
-- The veil goes up in the same breath: the capture table, installed
|
||||
-- before any session exists, so the whole encounter -- wipe, "Wild X
|
||||
-- appeared!", every beat between throws -- plays from the held head-on
|
||||
-- seat with the player's side out of the shot.
|
||||
mod.events:on("battle.started", function(payload)
|
||||
local b = payload and payload.battle
|
||||
if not (b and LetsGo.fullWild(b)) then return end
|
||||
if not (Voxel3D.available() and not vrOn()) then return end
|
||||
if not CatchThrow.pickBall() then return end
|
||||
local q = b.queue
|
||||
local n = q and #q or 0
|
||||
if n >= 6 and type(q[n]) == "table" and q[n].fn
|
||||
and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
|
||||
and q[n - 2] and q[n - 2].text
|
||||
and q[n - 3] and q[n - 3].fn
|
||||
and q[n - 4] and q[n - 4].wait
|
||||
and q[n - 5] and q[n - 5].fn then
|
||||
for _ = 1, 6 do table.remove(q) end
|
||||
end
|
||||
pcall(CatchThrow.veil, b)
|
||||
end)
|
||||
|
||||
-- a battle ending sweeps everything: the capture epilogue, the veil, a
|
||||
-- session a script tore down, and the exp context if the payout never
|
||||
-- fired
|
||||
mod.events:on("battle.ended", function()
|
||||
expCtx = nil
|
||||
pcall(CatchThrow.onBattleEnded)
|
||||
end)
|
||||
|
||||
CatchThrow.installInput()
|
||||
end
|
||||
|
||||
return LetsGo
|
||||
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
function Mat4.rotateZ(a)
|
||||
local c, s = math.cos(a), math.sin(a)
|
||||
return { c, -s, 0, 0,
|
||||
s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
-- The rotation a unit quaternion describes, row-major. The VR rig is what
|
||||
-- needs it: an OpenXR eye pose arrives as position + orientation
|
||||
-- quaternion, and both the eye's transform and its inverse (the view) are
|
||||
|
||||
+88
-27
@@ -355,6 +355,10 @@ end
|
||||
|
||||
function OverworldBattle.textRects(battle)
|
||||
if not battle or battle.blankForAskName then return {} end
|
||||
-- a capture session aiming has no text to put in the box and takes it
|
||||
-- off the frame (see the drawTextArea wrap): no box, no glass under it
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hideTextBox then return {} end
|
||||
local r = OverworldBattle.TEXT_RECT
|
||||
local out = { box = r.box }
|
||||
if battle.phase == "moveSelect" then
|
||||
@@ -615,7 +619,13 @@ function OverworldBattle.update(dt)
|
||||
-- was solved for (the slow drift aside, which was always there). Polled
|
||||
-- per frame rather than latched at battle start: the row is reachable
|
||||
-- from the mod manager's page mid-session.
|
||||
-- A LET'S GO capture session holds it too: the throw is aimed in this
|
||||
-- exact framing, and a camera that moved under a ball in flight would
|
||||
-- bend where the flick was pointed after the fact. (The session also
|
||||
-- sets BattleCam.still, which is what stops the drift -- see
|
||||
-- CatchThrow.begin.)
|
||||
BattleCam.steerable = not OverworldBattle.backPinned()
|
||||
and not BattleScene.capture
|
||||
-- the right stick, read as a rate before the rig is built from it: the
|
||||
-- wheel, the keys, the mouse and a drag all arrive as events and have
|
||||
-- already landed, but a stick is a HELD position and only a tick can
|
||||
@@ -698,15 +708,11 @@ function OverworldBattle.update(dt)
|
||||
-- reason the scene is: it binds a canvas of its own. After the frost, so
|
||||
-- the glass is frosted from the world alone and never from the glyphs
|
||||
-- about to sit on it.
|
||||
local ios = isIOS()
|
||||
local okHud, up = false, false
|
||||
if not ios then
|
||||
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||
end
|
||||
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||
session.snapped = (okHud and up) and true or false
|
||||
-- once per battle, not once per frame: a driver that cannot do this cannot
|
||||
-- do it sixty times a second either, and the fallback is silent and fine
|
||||
if not ios and not okHud and not session.hudWarned then
|
||||
if not okHud and not session.hudWarned then
|
||||
session.hudWarned = true
|
||||
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
|
||||
.. "in the battle frame this battle", tostring(up))
|
||||
@@ -751,6 +757,30 @@ function OverworldBattle.battle()
|
||||
return session.battle
|
||||
end
|
||||
|
||||
-- The staged fight's arena and floor height, for the capture mode: the
|
||||
-- foe's world cell is the far end of the throw and the floor is what a
|
||||
-- short ball bounces on. nil whenever there is nothing staged, which is
|
||||
-- one of the gates that sends a throw back to the engine's own toss.
|
||||
function OverworldBattle.arenaInfo()
|
||||
if not (session and session.arena and not session.broken) then return nil end
|
||||
local host = session.arena.map or (session.state and session.state.map)
|
||||
if not host then return nil end
|
||||
return session.arena, BattleScene.groundY(host, session.arena)
|
||||
end
|
||||
|
||||
-- The foe's rendered pic texture, for the capture mode's ring. The mark
|
||||
-- BattleScene pins is the CELL's ground point, but a species' art sits
|
||||
-- wherever the artist drew it in the frame -- a bird hovers half a slot
|
||||
-- above its own feet row -- and a timing ring belongs on the CREATURE,
|
||||
-- not on its patch of grass. The capture session reads this canvas back
|
||||
-- once and centres the ring on the art's opaque box. nil on the STADIUM
|
||||
-- rungs (the foe is a model, no pic is rendered) and before the first
|
||||
-- textures pass, both of which the caller treats as "use the heuristic".
|
||||
function OverworldBattle.enemyTexture()
|
||||
if not (session and session.textures) then return nil end
|
||||
return session.textures.enemy
|
||||
end
|
||||
|
||||
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
|
||||
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
|
||||
-- transparent canvas of its own. This is what stands the effects up in
|
||||
@@ -1107,7 +1137,12 @@ function OverworldBattle.textures(battle)
|
||||
local out = {}
|
||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
||||
local okP, player = true, nil
|
||||
if not OverworldBattle.backPinned() then
|
||||
-- a LET'S GO capture session empties the player's side the same way
|
||||
-- BACK SPRITES does: that mon is simply not in this shot, so no pic is
|
||||
-- rendered for it and no shadow lands under it
|
||||
local cap = BattleScene.capture
|
||||
if not OverworldBattle.backPinned()
|
||||
and not (cap and cap.hidePlayer) then
|
||||
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
end
|
||||
out.enemy = okE and enemy or nil
|
||||
@@ -1239,6 +1274,11 @@ function OverworldBattle.install()
|
||||
self.letterboxWhite = false
|
||||
OverworldBattle.drawHudPanels(self)
|
||||
withoutBackgroundFill(self, innerDraw)
|
||||
-- the LET'S GO capture overlay -- the timing ring, the ball readout,
|
||||
-- the grade splash -- drawn last in the same GB frame the engine's
|
||||
-- own HUD drew in, so it letterboxes and chunks identically
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.drawGB then pcall(cap.drawGB, self) end
|
||||
end
|
||||
|
||||
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
||||
@@ -1256,6 +1296,10 @@ function OverworldBattle.install()
|
||||
if not shot then
|
||||
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
||||
end
|
||||
-- a capture session shows NO player side at all -- not even the
|
||||
-- pinned back pic BACK SPRITES would keep on the menu
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hidePlayer then return end
|
||||
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
|
||||
-- under the hour's own light, like everything else in the frame -- see
|
||||
-- withTint, and the tint BattleScene hands over with the shot.
|
||||
@@ -1278,7 +1322,13 @@ function OverworldBattle.install()
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
if isIOS() then return innerText(self) end
|
||||
-- While a capture session is being AIMED the box is empty -- the
|
||||
-- battle's phase is parked, so there is no message in it -- and it
|
||||
-- covers the bottom third of the frame, which is exactly the room a
|
||||
-- throw needs to wind up in. So it comes off entirely for those
|
||||
-- frames and is back the instant a message has something to say.
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hideTextBox then return end
|
||||
return withoutBoxFill(self, innerText)
|
||||
end
|
||||
|
||||
@@ -1451,13 +1501,18 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
local rects, bandX = OverworldBattle.snapRects(shot)
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
local live = {}
|
||||
if enemy then live.enemy = rects.enemy end
|
||||
if player then live.player = rects.player end
|
||||
-- and the text box's own glass, on the same pass. It stays in the middle of
|
||||
-- the frame where the engine draws it -- only the HUDs were snapped out --
|
||||
-- so its GB rect is mapped into the letterbox rather than to an edge.
|
||||
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||
live[key] = toWorld(rect, shot)
|
||||
if not isIOS() then
|
||||
if enemy then live.enemy = rects.enemy end
|
||||
if player then live.player = rects.player end
|
||||
end
|
||||
-- The text box's frost panel normally goes into this same world-canvas pass.
|
||||
-- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating
|
||||
-- the large ghost rectangle behind the Pokemon. Keep the box border/text but
|
||||
-- skip only this frosted backing on iOS.
|
||||
if not isIOS() then
|
||||
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||
live[key] = toWorld(rect, shot)
|
||||
end
|
||||
end
|
||||
local layer = OverworldBattle.hudTexture(battle, slide)
|
||||
if not layer then return false end
|
||||
@@ -1473,8 +1528,24 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
for side, band in pairs(OverworldBattle.HUD_BAND) do
|
||||
local quad = g.newQuad(band[1], band[2], band[3], band[4],
|
||||
BattleScene.GB_W, BattleScene.GB_H)
|
||||
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
|
||||
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
|
||||
local x = bandX[side] + band[1] * shot.scale
|
||||
local targetY = shot.ly + band[2] * shot.scale
|
||||
|
||||
if isIOS() then
|
||||
-- Keep the player's HUD exactly where it currently appears on the
|
||||
-- right. Only the enemy band needs its mirrored destination corrected.
|
||||
local y = targetY
|
||||
if side == "enemy" then
|
||||
y = shot.ph - targetY - band[4] * shot.scale
|
||||
end
|
||||
|
||||
-- iOS presents this Canvas-to-Canvas HUD texture upside down.
|
||||
g.draw(layer, quad, x, y, 0,
|
||||
shot.scale, -shot.scale, 0, band[4])
|
||||
else
|
||||
g.draw(layer, quad, x, targetY, 0,
|
||||
shot.scale, shot.scale)
|
||||
end
|
||||
end
|
||||
end)
|
||||
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
||||
@@ -1494,16 +1565,6 @@ end
|
||||
function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
if not shot then return end
|
||||
if isIOS() then
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
local rect = OverworldBattle.HUD_RECT
|
||||
love.graphics.setColor(1, 1, 1, 0.84)
|
||||
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
|
||||
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
if snapped() then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws.
|
||||
--
|
||||
-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D
|
||||
-- sprite inside the battle's move-animation layer. This is a ball that can
|
||||
-- fly through the arena, hang in the air in front of the camera, hinge its
|
||||
-- lid open, drink a Pokemon in, click shut, rock on the ground and burst
|
||||
-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like
|
||||
-- everything else in the diorama, because it is a mesh in Voxel3D's own
|
||||
-- format going through Voxel3D's own shader.
|
||||
--
|
||||
-- ------- how it is built
|
||||
--
|
||||
-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base
|
||||
-- and the coloured LID -- each carrying its half of the black band as a
|
||||
-- slightly bulged latitude belt, so the two halves separate exactly where
|
||||
-- the real ball separates. The button is a little cylinder standing out of
|
||||
-- the base's front; the interior is sealed with two pale discs so an open
|
||||
-- ball shows a shell with a floor rather than a view through to the far
|
||||
-- wall's backface. Colour is a palette texture one texel per material and
|
||||
-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the
|
||||
-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow
|
||||
-- band without a second mesh, just a different V coordinate.
|
||||
--
|
||||
-- Shade is baked per vertex from the surface normal with StadiumStage's
|
||||
-- fitted constants, which is this mod's answer for anything curved: the
|
||||
-- ball's sun side and belly read as a sphere under the same southeastern
|
||||
-- sun the roofs are lit by.
|
||||
--
|
||||
-- ------- how it animates
|
||||
--
|
||||
-- The HordeGun way: a handful of scalar timers advanced by update(dt) and
|
||||
-- consumed as matrix terms at draw time. No skeleton, no keyframes --
|
||||
-- lid is a hinge matrix about the back of the equator, the wobble is a
|
||||
-- decaying rotateZ about the ground contact point, the caught click is a
|
||||
-- squash pulse, the stars are one shared quad drawn a few times facing the
|
||||
-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop)
|
||||
-- is the caller's problem, which is what keeps this file a prop and not a
|
||||
-- game mode.
|
||||
--
|
||||
-- Nothing here touches love.* until something has to be drawn, so the
|
||||
-- module loads and the state machine runs headless -- the test suite
|
||||
-- exercises the phases without a GPU.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
|
||||
local Pokeball = {}
|
||||
Pokeball.__index = Pokeball
|
||||
|
||||
-- ------- the ball's measurements, in world pixels
|
||||
--
|
||||
-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball
|
||||
-- sits in the hand and against a Pokemon at about the proportion the games
|
||||
-- draw: unmissable in the foreground, believable at the far cell.
|
||||
Pokeball.R = 2.2
|
||||
|
||||
-- the black belt: half-height as a latitude angle, and how far the belt
|
||||
-- bulges past the shell so it reads as a band and not a painted stripe
|
||||
local BAND_LAT = 0.16
|
||||
local BAND_R = 1.045
|
||||
|
||||
-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is
|
||||
-- past upright -- the mouth gapes at the sky, which is the capture pose.
|
||||
local HINGE_Z = -0.86 -- as a fraction of R
|
||||
local LID_OPEN = 2.0
|
||||
|
||||
-- tessellation: enough that the silhouette is round at held-ball size,
|
||||
-- cheap enough that six of these would not show on a phone's frame budget
|
||||
local LON = 14
|
||||
local LAT = 5
|
||||
|
||||
-- pose timing
|
||||
local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second
|
||||
local BURST_RATE = 14 -- the breakout pop is a violent open
|
||||
local WOBBLE_T = 0.85 -- one rock, seconds
|
||||
local WOBBLE_A = 0.38 -- how far it tips, radians
|
||||
local PULSE_T = 0.14 -- the caught click's squash pulse
|
||||
local STAR_T = 0.9 -- the caught stars' life
|
||||
local GLOW_DECAY = 2.2 -- additive glow, per second
|
||||
|
||||
-- ------- palette
|
||||
--
|
||||
-- One texel per material (columns), one row per ball tier. Alpha stays 1
|
||||
-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a
|
||||
-- translucent texel is an invisible one.
|
||||
local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5,
|
||||
INNER = 6, GLOW = 7, STAR = 8 }
|
||||
local SLOT_N = 8
|
||||
|
||||
local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL",
|
||||
"SAFARI_BALL" }
|
||||
|
||||
local COLORS = {
|
||||
POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 },
|
||||
bottom = { 0.93, 0.93, 0.95 },
|
||||
glow = { 1.0, 0.72, 0.92 } },
|
||||
SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 },
|
||||
bottom = { 0.90, 0.88, 0.80 } },
|
||||
}
|
||||
|
||||
local SHARED = {
|
||||
ring = { 0.28, 0.28, 0.30 },
|
||||
face = { 0.96, 0.96, 0.97 },
|
||||
inner = { 0.72, 0.70, 0.68 },
|
||||
glow = { 1.00, 0.92, 0.65 },
|
||||
star = { 1.00, 0.85, 0.25 },
|
||||
}
|
||||
|
||||
local function tierRow(ball)
|
||||
for i, id in ipairs(TIERS) do
|
||||
if id == ball then return i end
|
||||
end
|
||||
return 1 -- an unknown ball is a plain POKE BALL
|
||||
end
|
||||
|
||||
-- palette texel centres
|
||||
local function uvFor(slot, row)
|
||||
return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS
|
||||
end
|
||||
|
||||
local palette = nil
|
||||
local function paletteTexture()
|
||||
if palette ~= nil then return palette or nil end
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(SLOT_N, #TIERS)
|
||||
for row, id in ipairs(TIERS) do
|
||||
local c = COLORS[id]
|
||||
local function put(slot, rgb)
|
||||
data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1)
|
||||
end
|
||||
put(SLOTS.TOP, c.top)
|
||||
put(SLOTS.BOTTOM, c.bottom)
|
||||
put(SLOTS.BAND, c.band)
|
||||
put(SLOTS.RING, SHARED.ring)
|
||||
put(SLOTS.FACE, SHARED.face)
|
||||
put(SLOTS.INNER, SHARED.inner)
|
||||
put(SLOTS.GLOW, c.glow or SHARED.glow)
|
||||
put(SLOTS.STAR, SHARED.star)
|
||||
end
|
||||
local tex = love.graphics.newImage(data)
|
||||
tex:setFilter("nearest", "nearest")
|
||||
return tex
|
||||
end)
|
||||
palette = ok and img or false
|
||||
return palette or nil
|
||||
end
|
||||
|
||||
-- ------- shade
|
||||
--
|
||||
-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern
|
||||
-- sun, answered for an arbitrary normal instead of one of six faces.
|
||||
local function shadeFor(nx, ny, nz)
|
||||
local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11
|
||||
return math.max(0.30, math.min(1.00, s))
|
||||
end
|
||||
|
||||
-- ------- mesh building
|
||||
--
|
||||
-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices.
|
||||
-- Quads go through the shared corner order; the discs use a degenerate
|
||||
-- fourth vertex, which the rasteriser drops as the zero-area triangle it is.
|
||||
local function quad(verts, map, a, b, c, d)
|
||||
local n = #verts
|
||||
verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d
|
||||
Voxel3D.pushQuad(map, n / 4)
|
||||
end
|
||||
|
||||
local R = Pokeball.R
|
||||
local TAU = math.pi * 2
|
||||
|
||||
-- a latitude zone of the sphere between phi0 and phi1 (radians from the
|
||||
-- equator, north positive), at radiusK times the shell radius
|
||||
local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK)
|
||||
local u, v = uvFor(slot, row)
|
||||
local r = R * (radiusK or 1)
|
||||
for i = 0, rows - 1 do
|
||||
local pa = phi0 + (phi1 - phi0) * (i / rows)
|
||||
local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows)
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local function corner(phi, th)
|
||||
local nx = math.cos(phi) * math.sin(th)
|
||||
local ny = math.sin(phi)
|
||||
local nz = math.cos(phi) * math.cos(th)
|
||||
return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) }
|
||||
end
|
||||
quad(verts, map, corner(pa, ta), corner(pa, tb),
|
||||
corner(pb, tb), corner(pb, ta))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- a disc in a y-plane, sealed with fan quads about the centre
|
||||
local function disc(verts, map, y, radius, slot, row, up)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, up and 1 or -1, 0)
|
||||
local centre = { 0, y, 0, u, v, sh }
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh }
|
||||
local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
|
||||
-- the button: a ring wall and its face, standing out of the shell along +Z
|
||||
local function button(verts, map, row)
|
||||
local BLON = 10
|
||||
local function ringWall(rad, z0, z1, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local function at(th, z)
|
||||
local nx, ny = math.cos(th), math.sin(th)
|
||||
return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) }
|
||||
end
|
||||
quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1))
|
||||
end
|
||||
end
|
||||
local function faceDisc(rad, z, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, 0, 1)
|
||||
local centre = { 0, 0, z, u, v, sh }
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh }
|
||||
local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
-- the wall starts inside the shell so the junction never shows a gap
|
||||
ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING)
|
||||
faceDisc(0.75, R + 0.30, SLOTS.RING)
|
||||
ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING)
|
||||
faceDisc(0.45, R + 0.42, SLOTS.FACE)
|
||||
end
|
||||
|
||||
-- one tier's meshes, memoised: { base = , lid = , spark = }
|
||||
--
|
||||
-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel;
|
||||
-- the glow disc, the beam and every star are that card under a matrix.
|
||||
local meshes = {}
|
||||
local function meshesFor(ball)
|
||||
local row = tierRow(ball)
|
||||
local hit = meshes[row]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local ok, built = pcall(function()
|
||||
local bv, bm = {}, {}
|
||||
-- the base: white bowl from the south pole up to the band, its half of
|
||||
-- the band, the interior floor and the button on the front
|
||||
zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row)
|
||||
zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R)
|
||||
disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true)
|
||||
button(bv, bm, row)
|
||||
|
||||
local lv, lm = {}, {}
|
||||
-- the lid: its half of the band up to the coloured dome, and its pale
|
||||
-- underside, which is what shows once the hinge tips it back
|
||||
zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R)
|
||||
zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row)
|
||||
disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false)
|
||||
|
||||
local base = Voxel3D.newMesh(bv, bm)
|
||||
local lid = Voxel3D.newMesh(lv, lm)
|
||||
if not (base and lid) then return nil end
|
||||
|
||||
local function card(slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local cv, cm = {}, {}
|
||||
quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 },
|
||||
{ 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 })
|
||||
return Voxel3D.newMesh(cv, cm)
|
||||
end
|
||||
return { base = base, lid = lid,
|
||||
glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) }
|
||||
end)
|
||||
meshes[row] = (ok and built) or false
|
||||
return meshes[row] or nil
|
||||
end
|
||||
|
||||
-- dropped so a lost GL context (Android resume) rebuilds everything
|
||||
function Pokeball.invalidate()
|
||||
meshes = {}
|
||||
palette = nil
|
||||
end
|
||||
|
||||
-- ------- an instance: one ball with a pose
|
||||
--
|
||||
-- Loads and runs without graphics; only draw() and cast() want a GPU.
|
||||
function Pokeball.new(ball)
|
||||
return setmetatable({
|
||||
ball = ball or "POKE_BALL",
|
||||
pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE
|
||||
yaw = 0, -- which way the button faces
|
||||
scale = 1,
|
||||
spin = 0, -- visual spin about the vertical, rad/s
|
||||
tumble = 0, -- end-over-end in flight, rad/s
|
||||
roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about
|
||||
-- the axis out of the ball's face, which
|
||||
-- with the yaw at the camera reads as the
|
||||
-- ball turning clockwise/counter-clockwise
|
||||
-- to the viewer -- the curveball wind-up
|
||||
spinAngle = 0, tumbleAngle = 0, rollAngle = 0,
|
||||
lid = 0, lidTarget = 0, lidRate = LID_RATE,
|
||||
wobbleT = nil, wobbleDir = 1,
|
||||
pulse = nil, -- the caught click's squash
|
||||
glow = 0,
|
||||
stars = nil, -- caught celebration, or nil
|
||||
visible = true,
|
||||
}, Pokeball)
|
||||
end
|
||||
|
||||
-- ------- the verbs the capture flow speaks
|
||||
|
||||
function Pokeball:open()
|
||||
self.lidTarget, self.lidRate = 1, LID_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:close()
|
||||
self.lidTarget, self.lidRate = 0, LID_RATE
|
||||
end
|
||||
|
||||
-- one rock on the ground; dir alternates shakes. Returns how long it takes,
|
||||
-- so the caller can sequence the pauses between shakes.
|
||||
function Pokeball:rock(dir)
|
||||
self.wobbleT = 0
|
||||
self.wobbleDir = dir or 1
|
||||
return WOBBLE_T
|
||||
end
|
||||
|
||||
-- the caught click: squash pulse, a soft flash, and the stars
|
||||
function Pokeball:catchClick()
|
||||
self.pulse = 0
|
||||
self.glow = 0.6
|
||||
local stars = {}
|
||||
for i = 1, 6 do
|
||||
stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 }
|
||||
end
|
||||
self.stars = stars
|
||||
end
|
||||
|
||||
-- the breakout: the lid blown open and a hard flash
|
||||
function Pokeball:burst()
|
||||
self.lidTarget, self.lidRate = 1, BURST_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:busy()
|
||||
return self.wobbleT ~= nil or self.pulse ~= nil
|
||||
or math.abs(self.lid - self.lidTarget) > 0.02
|
||||
end
|
||||
|
||||
function Pokeball:update(dt)
|
||||
-- lid toward its target, at whatever violence was asked for
|
||||
local d = self.lidTarget - self.lid
|
||||
if d ~= 0 then
|
||||
local step = self.lidRate * dt
|
||||
if math.abs(d) <= step then
|
||||
-- arriving CLOSED from open is the shut click: the squash pulse
|
||||
if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end
|
||||
self.lid = self.lidTarget
|
||||
else
|
||||
self.lid = self.lid + (d > 0 and step or -step)
|
||||
end
|
||||
end
|
||||
if self.wobbleT then
|
||||
self.wobbleT = self.wobbleT + dt
|
||||
if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end
|
||||
end
|
||||
if self.pulse then
|
||||
self.pulse = self.pulse + dt
|
||||
if self.pulse >= PULSE_T then self.pulse = nil end
|
||||
end
|
||||
if self.stars then
|
||||
local live = false
|
||||
for _, s in ipairs(self.stars) do
|
||||
s.t = s.t + dt
|
||||
if s.t < STAR_T then live = true end
|
||||
end
|
||||
if not live then self.stars = nil end
|
||||
end
|
||||
self.glow = math.max(0, self.glow - GLOW_DECAY * dt)
|
||||
self.spinAngle = self.spinAngle + self.spin * dt
|
||||
self.tumbleAngle = self.tumbleAngle + self.tumble * dt
|
||||
self.rollAngle = self.rollAngle + self.roll * dt
|
||||
end
|
||||
|
||||
-- ------- pose as a matrix
|
||||
|
||||
local function smooth(t)
|
||||
if t <= 0 then return 0 end
|
||||
if t >= 1 then return 1 end
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
function Pokeball:matrix()
|
||||
local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]),
|
||||
Mat4.rotateY(self.yaw))
|
||||
if self.wobbleT then
|
||||
-- a decaying rock about the ground contact: tip, cross through centre,
|
||||
-- tip the other way, settle
|
||||
local t = self.wobbleT / WOBBLE_T
|
||||
local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir
|
||||
m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0),
|
||||
Mat4.mul(Mat4.rotateZ(a),
|
||||
Mat4.translate(0, R * self.scale, 0))))
|
||||
end
|
||||
if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end
|
||||
if self.tumbleAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle))
|
||||
end
|
||||
-- the roll turns about the ball's own face axis, so with the yaw aimed
|
||||
-- at the camera it reads as clockwise/counter-clockwise on screen
|
||||
if self.rollAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle))
|
||||
end
|
||||
local k = self.scale
|
||||
if self.pulse then
|
||||
-- the click: a quick squash and back, more felt than seen
|
||||
local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14
|
||||
m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p)))
|
||||
elseif k ~= 1 then
|
||||
m = Mat4.mul(m, Mat4.scale(k, k, k))
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
-- the hinge: the lid's own extra transform about the back of the equator
|
||||
local function lidMatrix(open)
|
||||
if open <= 0 then return nil end
|
||||
local a = -LID_OPEN * smooth(open)
|
||||
local hz = HINGE_Z * R
|
||||
return Mat4.mul(Mat4.translate(0, 0, hz),
|
||||
Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz)))
|
||||
end
|
||||
|
||||
-- where the open mouth is, for aiming the capture beam
|
||||
function Pokeball:mouth()
|
||||
return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3]
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly
|
||||
-- like Stadium.draw. Seams and glass are off for the duration: the ball is
|
||||
-- not on the voxel grid and does not wear the tileset atlas.
|
||||
local function eyeYaw(x, z)
|
||||
local eye = Voxel3D.eye
|
||||
if not eye then return 0 end
|
||||
return math.atan2(eye[1] - x, eye[3] - z)
|
||||
end
|
||||
|
||||
function Pokeball:draw(pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
local model = self:matrix()
|
||||
Voxel3D.draw(m.base, pal, model, pull)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull)
|
||||
|
||||
-- the additive dressing: the open-mouth glow and the caught stars.
|
||||
-- Depth writes are off under "add" (Voxel3D.blend), so these can never
|
||||
-- punch holes for later draws.
|
||||
local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars
|
||||
if anythingAdd then
|
||||
Voxel3D.blend("add")
|
||||
if self.glow > 0.05 and self.lid > 0.1 then
|
||||
-- a pulsing octahedron of light standing in the mouth: two crossed
|
||||
-- cards read from every seat in the house
|
||||
local gx, gy, gz = self:mouth()
|
||||
local s = R * (1.1 + 0.25 * self.glow) * self.scale
|
||||
for i = 0, 1 do
|
||||
local card = Mat4.mul(Mat4.translate(gx, gy, gz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2),
|
||||
Mat4.scale(s, s, 1)))
|
||||
Voxel3D.draw(m.glow, pal, card, pull)
|
||||
end
|
||||
end
|
||||
if self.stars then
|
||||
for _, s in ipairs(self.stars) do
|
||||
if s.t > 0 and s.t < STAR_T then
|
||||
local t = s.t / STAR_T
|
||||
local rr = (R + 4.5 * t) * self.scale
|
||||
local sx = self.pos[1] + math.sin(s.th) * rr
|
||||
local sz = self.pos[3] + math.cos(s.th) * rr
|
||||
local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale
|
||||
local sc = 1.1 * (1 - t)
|
||||
local card = Mat4.mul(Mat4.translate(sx, sy, sz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)),
|
||||
Mat4.mul(Mat4.rotateZ(TAU * t * 0.5),
|
||||
Mat4.scale(sc, sc, 1))))
|
||||
Voxel3D.draw(m.star, pal, card, pull)
|
||||
end
|
||||
end
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- the capture beam: a crossed pair of additive cards stretched from the
|
||||
-- ball's mouth to the mon it is drinking in. Separate from draw() because
|
||||
-- the caller owns the far end and the fade.
|
||||
function Pokeball:drawBeam(tx, ty, tz, width, strength, pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local x, y, z = self:mouth()
|
||||
local dx, dy, dz = tx - x, ty - y, tz - z
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len < 0.5 then return end
|
||||
dx, dy, dz = dx / len, dy / len, dz / len
|
||||
-- two perpendiculars to the beam axis
|
||||
local ux, uy, uz
|
||||
if math.abs(dy) < 0.94 then
|
||||
ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised
|
||||
local l = math.sqrt(ux * ux + uz * uz)
|
||||
ux, uz = ux / l, uz / l
|
||||
else
|
||||
ux, uy, uz = 1, 0, 0
|
||||
end
|
||||
local vx = dy * uz - dz * uy
|
||||
local vy = dz * ux - dx * uz
|
||||
local vz = dx * uy - dy * ux
|
||||
local w = (width or R) * (strength or 1)
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.blend("add")
|
||||
-- the unit card is x -0.5..0.5, y 0..1: columns map its x to a
|
||||
-- perpendicular and its y to the full run of the axis
|
||||
local a = { ux * w, dx * len, vx, x,
|
||||
uy * w, dy * len, vy, y,
|
||||
uz * w, dz * len, vz, z,
|
||||
0, 0, 0, 1 }
|
||||
local b = { vx * w, dx * len, ux, x,
|
||||
vy * w, dy * len, uy, y,
|
||||
vz * w, dz * len, uz, z,
|
||||
0, 0, 0, 1 }
|
||||
Voxel3D.draw(m.glow, pal, a, pull)
|
||||
Voxel3D.draw(m.glow, pal, b, pull)
|
||||
Voxel3D.blend(nil)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- ------- the sun's view
|
||||
--
|
||||
-- The same two shells under the same matrix, so the shadow on the ground is
|
||||
-- the pose the camera sees. The caller folds a term into the shadow
|
||||
-- signature while a ball is live (the sun pass is cached -- see
|
||||
-- BattleScene.shadowSignature) or this freezes on its first frame.
|
||||
function Pokeball:cast(shadowMap)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local model = self:matrix()
|
||||
shadowMap.draw(m.base, pal, model)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model)
|
||||
end
|
||||
|
||||
-- a term for the arena's cached shadow signature: quantised, so the cache
|
||||
-- only re-renders when the ball has visibly moved
|
||||
function Pokeball:signature()
|
||||
if not self.visible then return "" end
|
||||
return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4),
|
||||
math.floor(self.pos[3] * 4), math.floor(self.lid * 8),
|
||||
self.wobbleT and math.floor(self.wobbleT * 30) or -1 },
|
||||
",")
|
||||
end
|
||||
|
||||
return Pokeball
|
||||
@@ -391,6 +391,13 @@ function Stadium.update(dt, battle, groundY)
|
||||
if mon.species then StadiumPack.keep(mon.species) end
|
||||
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
|
||||
and not (battler and battler.substituteHP)
|
||||
-- LET'S GO capture mode: the player's model is out of the shot the
|
||||
-- same way its card and back pic are (the shrink half of the story is
|
||||
-- below, AFTER the grow block, which reassigns mon.scale every frame)
|
||||
local cap = V.require("BattleScene").capture
|
||||
if side == "player" and cap and cap.hidePlayer then
|
||||
mon.visible = false
|
||||
end
|
||||
-- cleared up front, so a side that has just lost its rig cannot leave
|
||||
-- last frame's matrix behind it
|
||||
mon.model_matrix = nil
|
||||
@@ -425,6 +432,16 @@ function Stadium.update(dt, battle, groundY)
|
||||
local okG, grow = pcall(battle.growInScale, battle, battler)
|
||||
mon.scale = (okG and grow) or 1
|
||||
end
|
||||
-- LET'S GO capture: the foe drinking into the ball. AFTER the grow
|
||||
-- block on purpose -- that block reassigns mon.scale every frame,
|
||||
-- and the first cut of this hook sat above it and was silently
|
||||
-- clobbered: the model stood at full size over a ball that had
|
||||
-- supposedly swallowed it. The session's fraction owns the scale
|
||||
-- for as long as it exists; the frame it clears, the grow block
|
||||
-- above is already putting the engine's own answer back.
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
mon.scale = cap.shrink
|
||||
end
|
||||
mon:update(dt or 0)
|
||||
if mon.visible and arena then
|
||||
local cell = arena[side]
|
||||
@@ -491,6 +508,34 @@ function Stadium.guard(side, mon, what, fn)
|
||||
return false
|
||||
end
|
||||
|
||||
-- The foe's body for the capture mode's collision and ring, when a MODEL
|
||||
-- is standing there instead of a pic: its own measured height and
|
||||
-- footprint, in world pixels. A model stands on the ground, so the body's
|
||||
-- centre is half its height up. nil whenever no model covers the foe,
|
||||
-- which sends CatchThrow to its pic measurement instead.
|
||||
function Stadium.captureBody()
|
||||
if not session then return nil end
|
||||
local mon = session.enemy
|
||||
if not (mon and mon.rig and mon.visible) then return nil end
|
||||
local okH, h = pcall(mon.worldHeight, mon)
|
||||
if not (okH and h and h > 0) then return nil end
|
||||
-- The POSED body, when there is one: a flying Pokemon is nowhere near
|
||||
-- the mark its cell projects to, and only the pose knows where it went
|
||||
-- (StadiumMon:bodySpan). The bind-pose figures stand in until the
|
||||
-- first skin, which is right for everything that keeps its feet down.
|
||||
local okS, centre, half, girth = pcall(mon.bodySpan, mon)
|
||||
if okS and centre then
|
||||
return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))),
|
||||
yOff = centre,
|
||||
hh = math.max(4, half) }
|
||||
end
|
||||
local okR, r = pcall(mon.worldRadius, mon)
|
||||
local rr = (okR and r and r > 0) and r or h * 0.4
|
||||
return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))),
|
||||
yOff = h * 0.5,
|
||||
hh = math.max(4, h * 0.55) }
|
||||
end
|
||||
|
||||
function Stadium.draw(pull)
|
||||
if not session then return end
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
|
||||
@@ -216,13 +216,14 @@ end
|
||||
|
||||
StadiumBuild.stance = stance
|
||||
|
||||
-- Whether this species' standby loop is corrupt in the source data.
|
||||
-- Whether this species' standby loop is corrupt as extracted.
|
||||
--
|
||||
-- A handful come out of the extraction with animations that throw bones
|
||||
-- hundreds of units off the body -- Exeggutor, Tangela and Magmar, whose
|
||||
-- channel streams the game's own index arithmetic evidently reads differently
|
||||
-- from the way this does. Played, they look like a Pokemon coming apart; the
|
||||
-- mod would rather stand them still.
|
||||
-- No species trips this today. Exeggutor, Tangela and Magmar used to, when
|
||||
-- the flags byte was misread and their hermite-keyframe animations were
|
||||
-- decoded as packed streams, throwing bones hundreds of units off the body.
|
||||
-- It stays as the guard against the next extraction bug: played, a broken
|
||||
-- idle looks like a Pokemon coming apart, and the mod would rather show
|
||||
-- the sprite fallback (see StadiumMon).
|
||||
--
|
||||
-- The test is deliberately narrow, because "differs from the bind pose" is NOT
|
||||
-- brokenness. It is asked only of the STANDBY loop -- the one animation that
|
||||
|
||||
+47
-19
@@ -22,9 +22,10 @@
|
||||
-- everything else is render state this rebuilds from the texture table
|
||||
-- instead.
|
||||
--
|
||||
-- THE ANIMATIONS are packed per-frame streams of 12- or 16-bit fields. The
|
||||
-- game also has a hermite-keyframe mode (flags & 8); it is ported for
|
||||
-- completeness but no animation of any of the 151 battle Pokemon uses it.
|
||||
-- THE ANIMATIONS are packed per-frame streams of 12- or 16-bit fields for
|
||||
-- 146 of the 151 species. The other five -- Pidgeot, Dodrio, Exeggutor,
|
||||
-- Tangela and Magmar -- use the game's hermite-keyframe mode (flags & 8),
|
||||
-- some for every animation, some for a subset.
|
||||
--
|
||||
-- ------- arithmetic, and why there are no bit operations here
|
||||
--
|
||||
@@ -86,10 +87,31 @@ local function roundHalfEven(x)
|
||||
return f + 1
|
||||
end
|
||||
|
||||
-- Python's round(x, nd). Multiplying by 10^nd first is WRONG: the multiply
|
||||
-- itself rounds, and a value just under a decimal boundary can land exactly
|
||||
-- on it -- Magmar's attack scale track holds 1.008874999..., whose *1e5 is
|
||||
-- exactly 100887.5, and the fold then differed from the oracle by one bit.
|
||||
-- Python rounds the EXACT binary expansion, and so does LuaJIT's own float
|
||||
-- formatter, so the digits come from string.format instead.
|
||||
local function roundTo(x, nd)
|
||||
if nd == 0 then return roundHalfEven(x) end
|
||||
local m = 10 ^ nd
|
||||
return roundHalfEven(x * m) / m
|
||||
if x ~= x or x == math.huge or x == -math.huge then return x end
|
||||
-- A true decimal tie -- the exact expansion terminating one digit past nd
|
||||
-- with a 5 -- only a dyadic value can produce, and its zeros then run out
|
||||
-- forever; any other double shows a nonzero digit within ~18 places. The
|
||||
-- formatter rounds such a tie away from zero where Python rounds it to
|
||||
-- even, so it is the one case the digits cannot settle.
|
||||
local s = string.format("%." .. (nd + 24) .. "f", x)
|
||||
local dot = s:find(".", 1, true)
|
||||
local tail = s:sub(dot + nd + 1)
|
||||
if not tail:match("^50*$") then
|
||||
return tonumber(string.format("%." .. nd .. "f", x))
|
||||
end
|
||||
local head = s:sub(1, dot + nd) -- sign, integer part, nd digits
|
||||
local last = head:byte(-1) - 48
|
||||
if last % 2 == 0 then return tonumber(head) end
|
||||
-- an odd digit bumps to even with no carry
|
||||
return tonumber(head:sub(1, -2) .. string.char(head:byte(-1) + 1))
|
||||
end
|
||||
|
||||
StadiumFragment.roundHalfEven = roundHalfEven
|
||||
@@ -572,7 +594,9 @@ Anim.__index = Anim
|
||||
local function newAnim(frag, off)
|
||||
return setmetatable({
|
||||
f = frag, off = off,
|
||||
flags = frag:u8(off),
|
||||
-- the u16 at +0: the flag bits live in its LOW byte, so a u8 read at +0
|
||||
-- gets the always-zero high byte and silently hides hermite mode
|
||||
flags = frag:u16(off),
|
||||
startFrame = frag:u16(off + 4),
|
||||
loopStart = frag:u16(off + 6),
|
||||
nChannels = frag:u16(off + 8),
|
||||
@@ -592,20 +616,20 @@ function Anim:chan(i)
|
||||
oRot = f:u16(o + 6), oTrans = f:u16(o + 8) }
|
||||
end
|
||||
|
||||
-- Packed per-frame streams (flags & 8 == 0), which is what every animation of
|
||||
-- every battle Pokemon actually uses.
|
||||
-- Packed per-frame streams (flags & 8 == 0).
|
||||
--
|
||||
-- A count of 0 means the component has no stream at all. The game's own index
|
||||
-- arithmetic (offset + count - 1) then runs off the front of the array -- for
|
||||
-- Tangela's idle the scale "array" is two entries long and the computed index
|
||||
-- is 99 -- so an empty channel is read as "keep the bind-pose value", which is
|
||||
-- what the nil return means to the caller.
|
||||
-- A count of 0 means the component has no stream. In the ROM that only ever
|
||||
-- happens in HERMITE animations, where a count under 2 means "the offset
|
||||
-- field IS the constant value" -- no packed animation of any of the 151
|
||||
-- species carries an empty channel, so the bind-pose fallback (the nil
|
||||
-- return) is dead code kept as a safety net.
|
||||
function Anim:transPacked(c, frame)
|
||||
if c.nTrans == 0 then return nil end
|
||||
local wide = floor(self.flags / 4) % 2 == 1
|
||||
local bits = wide and 16 or 12
|
||||
if c.nTrans == 1 then
|
||||
if wide then return c.oTrans + 0.0 end
|
||||
-- func_80016848 reads the u16 offset field back as a SIGNED constant
|
||||
if wide then return signed(c.oTrans, 16) + 0.0 end
|
||||
return floor(signed(c.oTrans * 16 % 65536, 16) / 16) + 0.0
|
||||
end
|
||||
local i = c.oTrans + (frame < c.nTrans - 1 and frame or c.nTrans - 1)
|
||||
@@ -626,9 +650,9 @@ function Anim:scalePacked(c, frame)
|
||||
return self.f:s16(self.scaleData + i * 2) / 1000.0
|
||||
end
|
||||
|
||||
-- Hermite keyframes (flags & 8). Ported for completeness: no animation of any
|
||||
-- of the 151 battle Pokemon sets that flag, which was measured rather than
|
||||
-- assumed.
|
||||
-- Hermite keyframes (flags & 8): Pidgeot, Dodrio, Exeggutor, Tangela and
|
||||
-- Magmar. src/17300.c func_80016934 (narrow, 6-byte keys) and func_80016B30
|
||||
-- (wide, 8-byte keys with a separate out-tangent).
|
||||
function Anim:hermite(base, n, frame, wide)
|
||||
local f = self.f
|
||||
local stride = wide and 8 or 6
|
||||
@@ -673,7 +697,10 @@ function Anim:rotKey(c, frame)
|
||||
floor(c.interp / 2) % 2 == 1) / 10.0
|
||||
end
|
||||
deg = deg % 360.0
|
||||
return floor(deg / 360.0 * 65536.0)
|
||||
-- func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above 180
|
||||
-- degrees to its negative twin, and the pack writer clamps i16, so an
|
||||
-- unwrapped value would pin at 32767 instead
|
||||
return signed(floor(deg / 360.0 * 65536.0) % 65536, 16)
|
||||
end
|
||||
|
||||
function Anim:scaleKey(c, frame)
|
||||
@@ -721,7 +748,8 @@ Aux.__index = Aux
|
||||
local function newAux(frag, off)
|
||||
return setmetatable({
|
||||
f = frag,
|
||||
flags = frag:u8(off),
|
||||
flags = frag:u16(off), -- low byte, same layout as Anim
|
||||
|
||||
startFrame = frag:u16(off + 4),
|
||||
loopStart = frag:u16(off + 6),
|
||||
nChannels = frag:u16(off + 8),
|
||||
|
||||
+15
-5
@@ -56,6 +56,13 @@ StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info"
|
||||
-- than misread. Must track StadiumPack's magic.
|
||||
StadiumInstall.FORMAT = "DSM3"
|
||||
|
||||
-- Bumped when the packs' CONTENT changes without the byte layout moving, so
|
||||
-- a cache built by an older extractor is rebuilt rather than trusted. Rev 2
|
||||
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
|
||||
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
|
||||
-- any rev-1 build.
|
||||
StadiumInstall.REV = 2
|
||||
|
||||
StadiumInstall.COUNT = 151
|
||||
|
||||
-- Named ROM files, then any ROM at all sitting in the folder.
|
||||
@@ -138,9 +145,10 @@ local function readMarker()
|
||||
if not (f and isFile(StadiumInstall.MARKER)) then return nil end
|
||||
local ok, text = pcall(f.read, StadiumInstall.MARKER)
|
||||
if not (ok and type(text) == "string") then return nil end
|
||||
local format, count, md5 = text:match("^(%S+)%s+(%d+)%s*(%S*)")
|
||||
local format, count, md5, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)")
|
||||
if not format then return nil end
|
||||
return { format = format, count = tonumber(count), md5 = md5 }
|
||||
return { format = format, count = tonumber(count), md5 = md5,
|
||||
rev = tonumber(rev) }
|
||||
end
|
||||
|
||||
-- Whether a complete, current set of packs is on disk.
|
||||
@@ -150,7 +158,8 @@ function StadiumInstall.ready()
|
||||
if readyCache ~= nil then return readyCache end
|
||||
local m = readMarker()
|
||||
readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT
|
||||
and m.count == StadiumInstall.COUNT) and true or false
|
||||
and m.count == StadiumInstall.COUNT
|
||||
and m.rev == StadiumInstall.REV) and true or false
|
||||
return readyCache
|
||||
end
|
||||
|
||||
@@ -315,8 +324,9 @@ function StadiumInstall.step()
|
||||
local wrote = #job.failed == 0 and job.total > 0
|
||||
if wrote and f then
|
||||
pcall(f.write, StadiumInstall.MARKER,
|
||||
("%s %d %s\n"):format(StadiumInstall.FORMAT, job.total,
|
||||
tostring(job.md5 or "")))
|
||||
("%s %d %s %d\n"):format(StadiumInstall.FORMAT, job.total,
|
||||
tostring(job.md5 or ""),
|
||||
StadiumInstall.REV))
|
||||
readyCache = nil
|
||||
StadiumPack.forget()
|
||||
end
|
||||
|
||||
+64
-24
@@ -205,32 +205,23 @@ end
|
||||
-- card, which is a per-POKEMON decline rather than a per-battle one: a fight
|
||||
-- can perfectly well have a model on one side and a pic on the other.
|
||||
--
|
||||
-- ------- the three the extraction cannot read
|
||||
-- ------- staticPose: the corrupt-idle escape hatch
|
||||
--
|
||||
-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that
|
||||
-- throw bones hundreds of units off the body -- the game's own index
|
||||
-- arithmetic evidently reads those channel streams differently from the way
|
||||
-- this does. StadiumBuild.idleIsBroken measures it and the pack carries the
|
||||
-- verdict as `staticPose`.
|
||||
-- StadiumBuild.idleIsBroken measures whether a species' standby loop throws
|
||||
-- bones off the body, and the pack carries the verdict as `staticPose`. A
|
||||
-- species so marked DECLINES here -- the Game Boy's own battle sprite
|
||||
-- stands on the tile instead, drawn by the same 2D-3D path every species
|
||||
-- uses when its model is unavailable -- because a bind pose held for a
|
||||
-- whole fight reads as broken, not as "this one does not animate".
|
||||
--
|
||||
-- That flag used to mean "stand this one still in its bind pose", on the
|
||||
-- reasoning that a Pokemon standing there looking like itself beats one
|
||||
-- coming apart. It does -- but only just. A bind pose is a RIGGING pose, not
|
||||
-- a portrait: arms out, neck straight, nothing where the artwork put it. Set
|
||||
-- among a hundred and forty-eight species that breathe, the three that hold
|
||||
-- a T-pose for the whole fight do not read as "these ones do not animate",
|
||||
-- they read as broken -- which they are.
|
||||
--
|
||||
-- So they decline instead, and the Game Boy's own battle sprite stands on
|
||||
-- the tile in front of the same arena, lit the same way, drawn by the same
|
||||
-- 2D-3D path every species uses when its model is unavailable. The mode
|
||||
-- already had that fallback for a species with no pack at all; this is three
|
||||
-- more species taking it.
|
||||
--
|
||||
-- Keyed on the DATA rather than on a list of dex numbers on purpose: the
|
||||
-- test that produced the flag is in the packer, so a re-extraction that
|
||||
-- fixes those streams -- or breaks a fourth species -- moves this with it
|
||||
-- and nothing here has to be edited.
|
||||
-- No species is marked today. Exeggutor, Tangela and Magmar used to be:
|
||||
-- their animations are hermite keyframes (flags & 8), the extractor misread
|
||||
-- the flags byte and decoded them as packed streams, and the exploding
|
||||
-- result tripped the detector (Pidgeot and Dodrio were garbled by the same
|
||||
-- bug, just not hard enough to trip it). The detector stays, keyed on the
|
||||
-- DATA rather than a list of dex numbers, so a future extraction bug that
|
||||
-- corrupts a species' idle falls back to the sprite instead of coming
|
||||
-- apart on the field -- and nothing here has to be edited when it does.
|
||||
function StadiumMon:setSpecies(dex)
|
||||
if dex == self.species then return self.rig ~= nil end
|
||||
if self.rig then self.rig:release() end
|
||||
@@ -465,6 +456,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
|
||||
Mat4.translate(0, -lift, 0))
|
||||
end
|
||||
|
||||
-- How far this Pokemon's LOWEST rendered point stands above the ground it
|
||||
-- is placed on, in world pixels -- the authored hover the matrix above
|
||||
-- gives back, actually applied.
|
||||
--
|
||||
-- Derived by repeating that matrix's own arithmetic rather than by
|
||||
-- re-deriving it in closed form: root scale, the model's floor and the
|
||||
-- hover cap interact in a way that is easy to get subtly wrong, and a
|
||||
-- caller that guessed would place things at the feet of a Pokemon that is
|
||||
-- flying. Which is exactly what a Pidgey does -- it renders a good third
|
||||
-- of its own height clear of its tile, and anything aimed at its cell
|
||||
-- mark lands under it.
|
||||
function StadiumMon:groundGap()
|
||||
local centre, half = self:bodySpan()
|
||||
if centre then return math.max(0, centre - half) end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Where this Pokemon's body actually SITS above the ground it is placed
|
||||
-- on, and how big it is: the centre height, the half height and the
|
||||
-- girth, all in world pixels.
|
||||
--
|
||||
-- Measured off the POSED vertices (StadiumRig:posedBounds) and put
|
||||
-- through this matrix's own scale and lift, so the answer is the shape
|
||||
-- the camera is about to see. That matters most for the species it is
|
||||
-- hardest to guess about: a Pidgey's standby animation flies it well
|
||||
-- clear of its tile, and anything aimed at its cell mark -- a capture
|
||||
-- ring, a thrown ball's collision -- lands under an empty patch of grass
|
||||
-- while the bird hovers above it. Nothing static says so; only the pose
|
||||
-- does.
|
||||
--
|
||||
-- nil before the first skin(), or with no rig: the caller falls back to
|
||||
-- the bind-pose height, which is right for everything that stands.
|
||||
function StadiumMon:bodySpan()
|
||||
local model, rig = self.model, self.rig
|
||||
if not (model and rig and rig.posedBounds) then return nil end
|
||||
local okB, lo, hi, girth = pcall(rig.posedBounds, rig)
|
||||
if not (okB and lo) then return nil end
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local k = root * self:worldHeight() / math.max(model.height, 1e-6)
|
||||
k = k * (self.scale or 1)
|
||||
local floor = model.floor or 0
|
||||
local hover = math.min(math.max(floor, 0),
|
||||
StadiumMon.HOVER_CAP * math.max(model.height, 0))
|
||||
local lift = (floor - hover) / root
|
||||
-- the same map the model matrix applies: world = k * (posed - lift)
|
||||
return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0)
|
||||
end
|
||||
|
||||
-- Pose and skin for this frame. Separate from the draw because both the
|
||||
-- SUN and the camera -- and, in a headset, both eyes -- want the same
|
||||
-- skinned mesh, and skinning it once is the whole reason this is worth
|
||||
|
||||
+16
-6
@@ -55,14 +55,22 @@ local floor = math.floor
|
||||
--
|
||||
-- DIR is inside the mod, and exists for a developer checkout that has run
|
||||
-- tools/stadium_pack.py -- which is also how the oracle the Lua extractor is
|
||||
-- tested against gets built. It is second because a locally built cache
|
||||
-- should win over whatever a checkout happens to have lying around.
|
||||
-- tested against gets built. It is second because a locally built CURRENT
|
||||
-- cache should win over whatever a checkout happens to have lying around --
|
||||
-- current as judged by StadiumInstall's marker, so a cache an old extractor
|
||||
-- built does not shadow a fresh set (see readPack).
|
||||
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
|
||||
StadiumPack.DIR = "assets/stadium"
|
||||
|
||||
local function readPack(species)
|
||||
-- The cache only counts when StadiumInstall's marker says it is a
|
||||
-- complete, CURRENT build -- an old cache (a rev the extractor has since
|
||||
-- fixed, a format that moved) must not shadow a fresh shipped set, and a
|
||||
-- half-written folder must not be read at all. Required lazily: Install
|
||||
-- requires this module at load, so the reverse edge cannot be taken then.
|
||||
local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species)
|
||||
if love and love.filesystem and love.filesystem.getInfo then
|
||||
if love and love.filesystem and love.filesystem.getInfo
|
||||
and V.require("StadiumInstall").ready() then
|
||||
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
|
||||
if okInfo and info then
|
||||
local ok, bytes = pcall(love.filesystem.read, rel)
|
||||
@@ -83,9 +91,11 @@ end
|
||||
-- contract and the packer's CONTEXTS must stay identical to it.
|
||||
-- Position 2 was called "hit" until the move table was read against it: it
|
||||
-- is the animation most of a species' MOVES play, which makes it the default
|
||||
-- attack and not a damage reaction (see StadiumMon's STATES). The name is a
|
||||
-- label on a position -- the format is the ORDER -- so renaming it changes no
|
||||
-- bytes, but it has to match tools/stadium_pack.py's CONTEXTS.
|
||||
-- attack and not a damage reaction (see StadiumMon's STATES). The slot TABLE
|
||||
-- is indexed by position, but the name also reaches the packed files: the
|
||||
-- packers bake it into the animation NAME strings, so it has to match
|
||||
-- tools/stadium_pack.py's CONTEXTS *and* pipeline/battle.py's CONTEXT_SLOTS,
|
||||
-- or the oracle diff reports every species.
|
||||
StadiumPack.CONTEXT = {
|
||||
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
|
||||
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
|
||||
|
||||
@@ -728,6 +728,36 @@ function StadiumRig:skin(yaw)
|
||||
end
|
||||
end
|
||||
|
||||
-- What this POSE actually occupies, in the rig's own posed space: the
|
||||
-- vertical span of every skinned vertex, and the furthest any of them
|
||||
-- stands from the model's vertical axis.
|
||||
--
|
||||
-- Read off the skinned rows rather than off the pack's bind-pose figures,
|
||||
-- because the two are not the same claim. The bind measurements say how
|
||||
-- big the model is; a caller placing something ON the Pokemon needs to
|
||||
-- know where the Pokemon IS, and for a flying species the standby
|
||||
-- animation carries it a third of its own height off the floor -- a lift
|
||||
-- that exists only in the posed bones and appears in no static field.
|
||||
--
|
||||
-- Answers nil before the first skin(), which is the caller's cue to fall
|
||||
-- back to the bind figures.
|
||||
function StadiumRig:posedBounds()
|
||||
local lo, hi, r2 = nil, nil, 0
|
||||
for _, part in ipairs(self.parts) do
|
||||
local rows, n = part.rows, part.prim.vertCount
|
||||
for k = 1, n do
|
||||
local row = rows[k]
|
||||
local y = row[2]
|
||||
if not lo or y < lo then lo = y end
|
||||
if not hi or y > hi then hi = y end
|
||||
local d = row[1] * row[1] + row[3] * row[3]
|
||||
if d > r2 then r2 = d end
|
||||
end
|
||||
end
|
||||
if not lo then return nil end
|
||||
return lo, hi, math.sqrt(r2)
|
||||
end
|
||||
|
||||
-- ------- which texture each part wears this frame
|
||||
--
|
||||
-- The eyes. A primitive whose display list carried geo command 0x23 with a
|
||||
|
||||
+31
-3
@@ -601,9 +601,22 @@ local PLANTER_SPRAY = { rows = 24, depth = 5 }
|
||||
-- states no profile, the honest reading is the one the thin standee pools
|
||||
-- exist for: the foliage stands as a per-pixel slab and keeps the airy
|
||||
-- silhouette that makes it read as leaves.
|
||||
-- `squash`, when given, is the PERCENT of its revolved depth every chord
|
||||
-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back.
|
||||
--
|
||||
-- A full revolve assumes the drawing's width is also its depth, which is
|
||||
-- true of a thing that really is round in plan (a hedge ball, a boulder,
|
||||
-- a trash can). A TREE is round in its canopy and thin at every other
|
||||
-- reading: the trunk is a stick, the crown is more air than wood, and the
|
||||
-- drawing is scenery seen from one side. Revolved at full width the little
|
||||
-- tree eats a whole cell of depth and reads as a boulder wearing bark, so
|
||||
-- the plan stays a circle and shrinks toward an ellipse: still round in
|
||||
-- section, still stepping pixel by pixel, just shallower. The chord is
|
||||
-- re-centred on the mid-plane, so the model neither slides nor detaches
|
||||
-- from the cells around it.
|
||||
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
|
||||
NYin, spray, baseRows, bodyRows, wellRows,
|
||||
taperVox)
|
||||
taperVox, squash)
|
||||
-- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
|
||||
-- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
|
||||
-- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
|
||||
@@ -864,6 +877,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
|
||||
+ 0.5))
|
||||
end
|
||||
if spray and iy < spray.rows then n = math.min(n, spray.depth) end
|
||||
if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end
|
||||
z0[i] = math.floor(N2 - n / 2 + 0.5)
|
||||
z1[i] = z0[i] + n
|
||||
-- a row the can's body band was repeated into wears the row it
|
||||
@@ -975,6 +989,9 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
|
||||
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
|
||||
+ 0.5))
|
||||
end
|
||||
if squash then
|
||||
n = math.max(1, math.floor(n * squash / 100 + 0.5))
|
||||
end
|
||||
z0[i] = math.floor(N2 - n / 2 + 0.5)
|
||||
z1[i] = z0[i] + n
|
||||
end
|
||||
@@ -1298,6 +1315,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
-- voxels the body band is repeated up to
|
||||
local stumpCap, canCap, canBase, canHeight, canWell, canTaper
|
||||
= 6, 9, 4, 9, 5, 4
|
||||
-- the sapling class's depth, as a PERCENT of the revolved chord
|
||||
local saplingSquash = 50
|
||||
do
|
||||
local okP, prof = pcall(V.data, "voxel_heights")
|
||||
local entry = okP and type(prof) == "table" and prof.tilesets
|
||||
@@ -1320,6 +1339,9 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
if entry and type(entry.can_taper) == "number" then
|
||||
canTaper = entry.can_taper
|
||||
end
|
||||
if entry and type(entry.sapling_squash) == "number" then
|
||||
saplingSquash = entry.sapling_squash
|
||||
end
|
||||
end
|
||||
|
||||
-- cells consumed by a 2x2 `canopy` group; the scan runs north to
|
||||
@@ -1439,13 +1461,19 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
local tall = s.class == "can" and canHeight or nil
|
||||
local well = s.class == "can" and canWell or nil
|
||||
local taper = s.class == "can" and canTaper or nil
|
||||
-- 100% is the full revolve, so it is the identity: never signed
|
||||
-- into the cache key, and never passed, by a class that has no
|
||||
-- squash of its own
|
||||
local squash = (s.class == "sapling" and saplingSquash ~= 100)
|
||||
and saplingSquash or nil
|
||||
local ground = false
|
||||
if data then
|
||||
local sig = tsid .. (cap and ("|c" .. cap) or "")
|
||||
.. (base and ("|b" .. base) or "")
|
||||
.. (tall and ("|h" .. tall) or "")
|
||||
.. (well and ("|w" .. well) or "")
|
||||
.. (taper and ("|t" .. taper) or "") .. "|"
|
||||
.. (taper and ("|t" .. taper) or "")
|
||||
.. (squash and ("|q" .. squash) or "") .. "|"
|
||||
.. gsig .. "|" .. table.concat({
|
||||
S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
|
||||
S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
|
||||
@@ -1454,7 +1482,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
if not tpl then
|
||||
local tq, tbg = roundTemplate(S, map, data, cx, cy,
|
||||
groundTiles, 16, cap, nil, nil,
|
||||
base, tall, well, taper)
|
||||
base, tall, well, taper, squash)
|
||||
tpl = { quads = tq, bg = tbg }
|
||||
roundCache[sig] = tpl
|
||||
end
|
||||
|
||||
@@ -80,6 +80,14 @@ local FALLBACK_HEIGHTS = {
|
||||
-- the drawing's own straight run is only a couple of rows, because a GB
|
||||
-- cell spends most of itself on the opening
|
||||
can = 9,
|
||||
-- the same hull SQUASHED front to back (the profile's sapling_squash,
|
||||
-- a percent of the revolved depth): the little trees drawn one cell
|
||||
-- wide -- Celadon Gym's garden trees and the overworld's cuttable
|
||||
-- tree, which are the same drawing on two atlases. A tree is round in
|
||||
-- its canopy but is not a boulder: revolved at full width it fills a
|
||||
-- whole cell of depth, so the plan keeps its circle and shrinks toward
|
||||
-- an ellipse
|
||||
sapling = 16,
|
||||
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
|
||||
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
|
||||
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
|
||||
@@ -147,6 +155,7 @@ local ART = {
|
||||
canopy = "canopy",
|
||||
stump = "cylinder",
|
||||
can = "cylinder",
|
||||
sapling = "cylinder",
|
||||
planter = "planter",
|
||||
billboard = "billboard",
|
||||
-- signposts share the billboard treatment but as their own pool at a
|
||||
|
||||
+219
-18
@@ -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,47 @@ 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
|
||||
-- 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
|
||||
-- life scale, turned to face the arena
|
||||
@@ -332,8 +419,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 +470,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 +622,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 +648,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 +711,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 +727,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 +764,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 +810,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 +849,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 +894,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 +924,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 +935,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 +978,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
|
||||
|
||||
+36
-10
@@ -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
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
-- RENDER DIST: how much of the map the orbit rungs bother to draw.
|
||||
--
|
||||
-- lib/Diorama cuts a headset's model out of the world with a box the
|
||||
-- player opens and closes with their hands. This is the same cut on the
|
||||
-- flat screen, asked the other way round: not "how much world do I want to
|
||||
-- be holding" but "how much world can this camera actually SEE" -- so that
|
||||
-- nothing off screen is drawn, and nothing on screen is missing.
|
||||
--
|
||||
-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the
|
||||
-- first cut of this file got it wrong: it took the flat game's own vw-by-vh
|
||||
-- rectangle about the view centre, which is exactly right at 0 degrees and
|
||||
-- wrong at every rung the mode actually has. Tilt the camera and the ground
|
||||
-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching
|
||||
-- much further north (the far edge of the frame is further away, so it
|
||||
-- covers more ground per pixel), flaring much wider there for the same
|
||||
-- reason, and pulling IN at the south edge, which is nearer the eye than
|
||||
-- the focus is. A window-sized box cuts the north field and both far
|
||||
-- corners off a world that is plainly on screen: gaps at the top and down
|
||||
-- the sides, with sky showing through them.
|
||||
--
|
||||
-- So the footprint is derived from the camera rather than guessed. The
|
||||
-- orbit is one number (see Voxel3D.viewProjection): eye at distance
|
||||
-- FOCAL*vh, pitched `a` off straight down, looking at the view centre,
|
||||
-- with a fov chosen so a straight-down camera frames exactly vh. Cast the
|
||||
-- frame's own corner rays at the ground plane and the trapezoid falls out
|
||||
-- in closed form -- see footprint() for the derivation, which is three
|
||||
-- lines of algebra and no tuning at all.
|
||||
--
|
||||
-- The CUT is still a rectangle (the shader's box kind), so what is stored
|
||||
-- is the trapezoid's bounding rect: never narrower than the picture, so it
|
||||
-- can never take a bite out of it. It is off-centre in z, because the
|
||||
-- trapezoid is -- the box sits north of the view centre at every rung but
|
||||
-- the top one.
|
||||
--
|
||||
-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly
|
||||
-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the
|
||||
-- trapezoid stops being finite: the camera can see to the horizon, and "all
|
||||
-- the ground on screen" is an infinite answer. Something has to name a
|
||||
-- distance, and that is what RENDER DIST names -- MAX_REACH view heights,
|
||||
-- times the row's own multiplier. Below that pitch the row does nothing to
|
||||
-- the picture at all, because the honest footprint is already smaller than
|
||||
-- the reach; at 75 it is what decides where the world ends.
|
||||
--
|
||||
-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is
|
||||
-- one VoxelScene can test a whole neighbour map against BEFORE drawing it
|
||||
-- -- see shows() -- so a connected map that lands entirely outside the box
|
||||
-- costs no terrain mesh, no water, no grass, no flowers and no shadow
|
||||
-- pass. Most of the win is at the HIGH rungs, where the camera is nearly
|
||||
-- overhead and the footprint is barely bigger than the window; at 75 it
|
||||
-- sees half the region and skips almost nothing, which is the truth about
|
||||
-- that rung rather than a shortcoming of the test.
|
||||
--
|
||||
-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is
|
||||
-- standing IN the world there, and a box around a walking eye is a
|
||||
-- fog-of-war circle rather than a model on a table. The rung tween into
|
||||
-- them opens the box out with the blend rather than dropping it on a
|
||||
-- frame, so diving into a head does not pop the sides away. A headset's
|
||||
-- frame is not touched either -- lib/Diorama owns the cut there, and VR's
|
||||
-- STANDARD rungs are a tabletop already.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local ViewBox = {}
|
||||
|
||||
ViewBox.KEY = "viewbox"
|
||||
-- RENDER DIST rather than a V- name like the rows either side of it: what
|
||||
-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing
|
||||
-- every game with this row calls a render distance. The mode read -- the
|
||||
-- slab with sides -- is what that buys, not what the row is asking.
|
||||
ViewBox.LABEL = "RENDER DIST"
|
||||
|
||||
-- The ladder, as a multiplier on the footprint the camera actually frames.
|
||||
-- Rung 0 is FIT and it is the default, and FIT means exactly that: the
|
||||
-- ground on screen and no more. It cannot open a gap -- 1.0 times the
|
||||
-- honest answer is the honest answer -- so the only thing the wider rungs
|
||||
-- buy below the horizon pitch is margin around a cut nobody can see.
|
||||
--
|
||||
-- Where they DO decide the picture is at 75, and at the tween rungs either
|
||||
-- side of it, where the footprint is infinite and MAX_REACH below stands in
|
||||
-- for it: there the ladder is a real render distance and FIT is the closest
|
||||
-- horizon of the four.
|
||||
--
|
||||
-- Geometric rather than even, for the reason WorldCurve's ladder is: what
|
||||
-- the player sees change between two rungs is the AREA inside the box, and
|
||||
-- that goes as the square -- even steps bunch the whole ladder at the
|
||||
-- near end.
|
||||
--
|
||||
-- The last rung is 0, which is no cut at all. It sits at the TOP because
|
||||
-- "everything" is where the ladder is going: FIT, wider, wider, wider,
|
||||
-- all of it.
|
||||
ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 }
|
||||
|
||||
ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
|
||||
{ 0, 1, 2, 3, 4 },
|
||||
{ "FIT", "WIDE", "WIDER", "WIDEST", "OFF" })
|
||||
|
||||
-- How far the world may reach when the camera can see the HORIZON and the
|
||||
-- honest footprint is infinite, in view heights. Generous on purpose: this
|
||||
-- is a backstop for an unbounded answer, not a curtain to draw across the
|
||||
-- middle distance, and it wants to land well past the edge of the loaded
|
||||
-- neighbourhood so the world runs out before the cut does. Multiplied by
|
||||
-- the row, so a player who can see the seam can push it away.
|
||||
ViewBox.MAX_REACH = 6
|
||||
|
||||
-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
|
||||
-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
|
||||
-- so a hard edge across one is a lie about what is being looked at. Flat,
|
||||
-- the box keeps its hard edge -- that IS the sides.
|
||||
ViewBox.FADE_FRAC = 0.16
|
||||
|
||||
-- How far outside the box a map may still have geometry inside it: the
|
||||
-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32
|
||||
-- world pixels), which is the one thing a map draws beyond its own
|
||||
-- rectangle. A neighbour kept by this margin that turns out to be entirely
|
||||
-- outside is drawn and then cut per fragment, which is what would have
|
||||
-- happened without the test -- the margin can only cost a draw, never a
|
||||
-- hole.
|
||||
ViewBox.PAD = 96
|
||||
|
||||
function ViewBox.level()
|
||||
return ViewBox.setting:get() or 0
|
||||
end
|
||||
|
||||
-- The multiplier in force, or nil for OFF -- which is also every caller's
|
||||
-- "there is no cut this frame" answer.
|
||||
function ViewBox.frac()
|
||||
local f = ViewBox.FRACS[ViewBox.level() + 1]
|
||||
if not f or f <= 0 then return nil end
|
||||
return f
|
||||
end
|
||||
|
||||
-- Whether this rung is one the box is about: an ORBIT rung, which is every
|
||||
-- level the mode has except OFF (level 0, where there is no 3D pass to cut)
|
||||
-- and the two free-roam rungs (see the header).
|
||||
function ViewBox.appliesTo(level)
|
||||
local ok, applies = pcall(function()
|
||||
local Voxel = V.require("VoxelState")
|
||||
local l = level or Voxel.level or 0
|
||||
return l > 0 and not Voxel.isFreeCam(l)
|
||||
end)
|
||||
return ok and applies or false
|
||||
end
|
||||
|
||||
-- ------- what the live frame is
|
||||
--
|
||||
-- Set by VoxelScene for the length of one flat frame and cleared with it,
|
||||
-- exactly as Diorama's is for a headset's. Nothing else writes it, and
|
||||
-- every reader -- the shader uniforms, the neighbour skip -- hangs off this
|
||||
-- one field being nil or not.
|
||||
ViewBox.cull = nil -- { x, y, z, r, rx, rz, invFade, kind }
|
||||
|
||||
local function curved()
|
||||
local ok, on = pcall(function()
|
||||
return V.require("WorldCurve").active()
|
||||
end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
-- How far out of the orbit and into a walking head the rung tween has got,
|
||||
-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has
|
||||
-- grown past every edge of the frame by the time the eye arrives in the
|
||||
-- head and the cut is dropped -- rather than the sides vanishing on the
|
||||
-- frame the rung number changed, which is a pop in the middle of a move.
|
||||
local function orbitLeft()
|
||||
local ok, blend = pcall(function()
|
||||
return V.require("FirstPerson").blendEased()
|
||||
end)
|
||||
if not ok or type(blend) ~= "number" then return 1 end
|
||||
return 1 - math.max(0, math.min(1, blend))
|
||||
end
|
||||
|
||||
-- ------- the ground this camera frames
|
||||
--
|
||||
-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance
|
||||
-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus;
|
||||
-- focus on the ground at the view centre; a symmetric frustum whose
|
||||
-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh)
|
||||
-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the
|
||||
-- TOP of the frame, which is north.
|
||||
--
|
||||
-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY,
|
||||
-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a),
|
||||
-- up = (0, sin a, -cos a)) that comes out as
|
||||
--
|
||||
-- d = ( sx*tanX, -cos a + sy*tanY*sin a, -sin a - sy*tanY*cos a )
|
||||
--
|
||||
-- Drop it to the ground plane from an eye at height k*cos a and the whole
|
||||
-- trapezoid collapses to ONE denominator,
|
||||
--
|
||||
-- D(sy) = cos a - sy*tanY*sin a
|
||||
--
|
||||
-- with (the sin^2 + cos^2 cancels most of the algebra away):
|
||||
--
|
||||
-- north of centre : (vh/2) * sy / D(sy)
|
||||
-- half-width : (vw/2) * cos a / D(sy)
|
||||
--
|
||||
-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL
|
||||
-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is
|
||||
-- the case the first cut of this file mistook for all of them.
|
||||
--
|
||||
-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and
|
||||
-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon
|
||||
-- sits on. Past 63 degrees that row is inside the frame and the answer is
|
||||
-- infinite -- which is what `reach` is for.
|
||||
--
|
||||
-- Returns three DISTANCES from the view centre, all positive: how far the
|
||||
-- picture runs north, how far south, and how far to each side.
|
||||
function ViewBox.footprint(a, vw, vh, reach)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5
|
||||
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
|
||||
-- the orbit never reaches level (75 degrees is the last rung) but a tween
|
||||
-- reads a live angle, and a cos of zero is a horizon through the middle
|
||||
-- of the frame rather than a number
|
||||
local ca = math.max(math.cos(a or 0), 1e-3)
|
||||
local sa = math.max(math.sin(a or 0), 0)
|
||||
-- The screen row the far edge is taken at: the TOP of the frame, or the
|
||||
-- row whose ray lands `reach` out, whichever comes first. Inverting the
|
||||
-- north formula for sy gives
|
||||
--
|
||||
-- sy = reach*cos a / (vh/2 + reach*tanY*sin a)
|
||||
--
|
||||
-- which is always strictly below the horizon row (it approaches it from
|
||||
-- underneath as reach grows), so D below is always positive -- with the
|
||||
-- horizon in frame this never even reaches 1 and the clamp is inert.
|
||||
local sy = reach * ca / (halfH + reach * tanY * sa)
|
||||
if sy > 1 then sy = 1 end
|
||||
local D = ca - sy * tanY * sa
|
||||
if D < 1e-3 then D = 1e-3 end
|
||||
return halfH * sy / D, -- north
|
||||
halfH / (ca + tanY * sa), -- south: the sy = -1 row
|
||||
halfW * ca / D -- and the widest row is the far one
|
||||
end
|
||||
|
||||
-- Open the frame's cut: the bounding rectangle of the ground this camera
|
||||
-- frames, times the row's multiplier. Returns the cut, or nil when this
|
||||
-- frame has none -- which is the row at OFF, a rung the box is not about,
|
||||
-- and a camera that has finished its dive into a head.
|
||||
function ViewBox.frame(cx, cy, vw, vh, level)
|
||||
ViewBox.cull = nil
|
||||
local frac = ViewBox.frac()
|
||||
if not (frac and ViewBox.appliesTo(level)) then return nil end
|
||||
local left = orbitLeft()
|
||||
if left <= 0.001 then return nil end
|
||||
frac = frac / left
|
||||
local Voxel = V.require("VoxelState")
|
||||
local north, south, side = ViewBox.footprint(
|
||||
Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288))
|
||||
north, south, side = north * frac, south * frac, side * frac
|
||||
-- The rectangle around it. Off-centre in z, because the trapezoid is:
|
||||
-- the camera looks NORTH from south of its focus, so there is far more
|
||||
-- picture ahead of the view centre than behind it -- at 35 degrees
|
||||
-- roughly twice as much, at 75 the whole frame.
|
||||
--
|
||||
-- The same floor Diorama.radius keeps: a box smaller than a couple of
|
||||
-- tiles is not a viewport, it is a hole the player is standing in.
|
||||
local rx = math.max(24, side)
|
||||
local rz = math.max(24, (north + south) * 0.5)
|
||||
local bent = curved()
|
||||
local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0
|
||||
ViewBox.cull = {
|
||||
x = cx, y = 0, z = cy - (north - south) * 0.5,
|
||||
-- `r` is what the ball and the pillar kinds are sized by and the box
|
||||
-- is not; carried so the cut table has one shape whoever made it
|
||||
r = math.max(rx, rz), rx = rx, rz = rz,
|
||||
-- a zero band is a hard edge: half a pixel of ramp, which is one pixel
|
||||
-- of antialiasing rather than a stair (Diorama says the same)
|
||||
invFade = 1 / math.max(fade, 0.5),
|
||||
kind = V.require("Diorama").BOX,
|
||||
}
|
||||
return ViewBox.cull
|
||||
end
|
||||
|
||||
function ViewBox.stop()
|
||||
ViewBox.cull = nil
|
||||
end
|
||||
|
||||
-- ------- the coarse half of the cut
|
||||
--
|
||||
-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can
|
||||
-- be inside this frame's box. True whenever there is no box, so a caller
|
||||
-- may guard every draw with it unconditionally.
|
||||
function ViewBox.shows(x0, z0, x1, z1)
|
||||
local c = ViewBox.cull
|
||||
if not c then return true end
|
||||
local pad = ViewBox.PAD
|
||||
return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx
|
||||
and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz
|
||||
end
|
||||
|
||||
-- The same question about a connected neighbour, in the shape VoxelScene
|
||||
-- keeps them: { map, ox, oy } with the offset in world pixels and the map's
|
||||
-- own size in blocks of 32 (the shape prefetch's masks are built from).
|
||||
function ViewBox.showsMap(nb)
|
||||
if not (nb and nb.map and nb.map.def) then return true end
|
||||
return ViewBox.shows(nb.ox or 0, nb.oy or 0,
|
||||
(nb.ox or 0) + (nb.map.def.width or 0) * 32,
|
||||
(nb.oy or 0) + (nb.map.def.height or 0) * 32)
|
||||
end
|
||||
|
||||
-- What the shadow pass has to notice: WHICH neighbours it drew is now a
|
||||
-- function of the row, and the row is the one input to that the sun's own
|
||||
-- signature does not already carry (the centre, the view size and the
|
||||
-- rung are all in it). Widening the box brings a neighbour back into the
|
||||
-- light's frustum, and a map recorded without it must be redrawn.
|
||||
function ViewBox.signature()
|
||||
return ViewBox.frac() or 0
|
||||
end
|
||||
|
||||
function ViewBox.row()
|
||||
return ViewBox.setting:row()
|
||||
end
|
||||
|
||||
function ViewBox.sync(value)
|
||||
ViewBox.setting:sync(value)
|
||||
end
|
||||
|
||||
return ViewBox
|
||||
+205
-21
@@ -72,6 +72,14 @@ Voxel3D.FACE_SHADE = {
|
||||
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
|
||||
@@ -87,6 +95,7 @@ local SHADER = [[
|
||||
uniform vec3 eye;
|
||||
uniform float pull;
|
||||
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
|
||||
uniform vec4 fogInfo; // density, start, heightK; density 0 = clear
|
||||
attribute float VertexShade;
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
vShade = VertexShade;
|
||||
@@ -108,6 +117,32 @@ local SHADER = [[
|
||||
// answered. (The pull below is excluded for the same reason: it is a
|
||||
// depth trick aimed at the camera's own buffer.)
|
||||
vSun = (sunVP * (sunModel * vertex_position)).xyz;
|
||||
// THE MAP'S HAZE (see ForestAtmos): how much fog stands between the
|
||||
// eye and this vertex -- distance dissolves into it, altitude climbs
|
||||
// out of it. Worked out on the FLAT world like the shadow lookup
|
||||
// above (the curve is a trick played on the viewer, not weather),
|
||||
// and per VERTEX: on meshes built a face per voxel the interpolated
|
||||
// answer is indistinguishable from per-fragment fog at a fraction of
|
||||
// the cost.
|
||||
vFog = 0.0;
|
||||
if (fogInfo.x > 0.0) {
|
||||
float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y);
|
||||
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
|
||||
@@ -132,6 +167,46 @@ 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
|
||||
uniform vec2 cullRect; // the BOX's half-extents in x and z, which
|
||||
// the round kinds have no use for. Two
|
||||
// numbers because the flat screen's box is
|
||||
// the WINDOW's own footprint and a window is
|
||||
// not square (lib/ViewBox); a headset's is,
|
||||
// and lib/Diorama sends the same half-size
|
||||
// twice.
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Every kind is unbounded upward and downward on purpose: what is wanted
|
||||
// is a rectangular (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;
|
||||
// how far INSIDE the cut this point is, in world pixels: the nearest
|
||||
// side for the rectangle, the rim for the two round kinds
|
||||
float inside;
|
||||
if (cullShape.z < 1.5) {
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd); // the ball, under V-CURVE
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz); // the fight's pillar
|
||||
}
|
||||
return clamp(inside * 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;
|
||||
@@ -205,6 +280,7 @@ local SHADER = [[
|
||||
uniform vec3 ghostColor; // the flat silhouette colour
|
||||
uniform float ghost; // 0 = shade normally, 1 = flatten to it
|
||||
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
|
||||
uniform vec3 fogColor; // what the haze is made of (see Voxel3D.fog)
|
||||
uniform Image glassMask; // opaque where the atlas texel is window glass
|
||||
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
|
||||
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
|
||||
@@ -218,6 +294,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;
|
||||
@@ -256,6 +341,11 @@ local SHADER = [[
|
||||
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
|
||||
rgb = mix(pane, lamp, glassNight * glass);
|
||||
}
|
||||
// the haze stands between the eye and the SURFACE, so it lands after
|
||||
// every surface term -- sun, seams, glass -- and before only the
|
||||
// ghost, which must stay one solid readable shape whatever the
|
||||
// weather (see below)
|
||||
rgb = mix(rgb, fogColor, vFog);
|
||||
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
|
||||
// through `color` could only multiply the sprite's own pixels, which
|
||||
// darkens each one by its own amount and keeps the character's internal
|
||||
@@ -263,17 +353,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
|
||||
@@ -356,21 +462,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
|
||||
@@ -687,6 +796,30 @@ end
|
||||
-- answers it, so a caller that never does draws exactly what it always drew.
|
||||
Voxel3D.tint = { 1, 1, 1 }
|
||||
|
||||
-- The map's haze, set the same way (VoxelScene and BattleScene ask
|
||||
-- ForestAtmos, who knows which maps have weather): a table of
|
||||
-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day.
|
||||
-- nil -- the default -- sends density 0, so a caller that never heard of
|
||||
-- fog draws exactly what it always drew, and no pass can inherit the
|
||||
-- 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
|
||||
@@ -810,12 +943,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"
|
||||
@@ -864,10 +1005,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
|
||||
@@ -922,6 +1071,22 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
|
||||
-- the hour's light, as the caller last set it (see Voxel3D.tint)
|
||||
pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
|
||||
-- and the map's haze (see Voxel3D.fog), density 0 when there is none
|
||||
local fog = Voxel3D.fog
|
||||
pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 })
|
||||
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 viewport (see Voxel3D.cull), kind 0 when there is none --
|
||||
-- which is every frame that neither a headset's diorama (lib/Diorama)
|
||||
-- nor an orbit rung's window box (lib/ViewBox) has cut
|
||||
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 })
|
||||
pcall(sh.send, sh, "cullRect",
|
||||
cull and { cull.rx or cull.r, cull.rz or cull.r } or { 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
|
||||
@@ -1127,6 +1292,25 @@ function Voxel3D.endWater()
|
||||
if activeShader then love.graphics.setShader(activeShader) end
|
||||
end
|
||||
|
||||
-- A custom shader for the length of a draw, inside the pass. What makes
|
||||
-- this a pair rather than a bare setShader at the call site is the way
|
||||
-- BACK: the scene shader this pass bound is module-local (activeShader,
|
||||
-- above), so only this file can restore it -- the same restore endWater
|
||||
-- performs, without the canvas shuffle. Answers false when there is no
|
||||
-- pass to come back to, and the caller skips its draw entirely.
|
||||
function Voxel3D.beginEffect(shader)
|
||||
if not (active and shader) then return false end
|
||||
love.graphics.setShader(shader)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return true
|
||||
end
|
||||
|
||||
function Voxel3D.endEffect()
|
||||
if not active then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if activeShader then love.graphics.setShader(activeShader) end
|
||||
end
|
||||
|
||||
-- Whether a reflective water pass can run in this frame at all -- there is
|
||||
-- a depth texture to read. Callers use it to choose between the water
|
||||
-- shader and an ordinary terrain draw before they start moving canvases.
|
||||
|
||||
+105
-25
@@ -27,6 +27,8 @@ 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 ViewBox = V.require("ViewBox")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -622,10 +624,12 @@ local function drawCast(state, posed, atlasFor)
|
||||
ShadowMap.snug(caster))
|
||||
end)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
|
||||
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
|
||||
ShadowMap.snug(caster))
|
||||
end)
|
||||
if ViewBox.showsMap(nb) then
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
|
||||
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
|
||||
ShadowMap.snug(caster))
|
||||
end)
|
||||
end
|
||||
end
|
||||
-- and the seams are back on for the terrain art that follows: grass and
|
||||
-- flowers are the world's own drawing, not people
|
||||
@@ -769,6 +773,11 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||
-- and the sprite cards swap frames as it circles them, so a turn on the
|
||||
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
|
||||
put(FirstPerson.signature())
|
||||
-- and the window box, because WHICH neighbours went into the light is a
|
||||
-- function of it (see ViewBox.signature): opening the row out brings a
|
||||
-- map back inside the cut, and a sun map recorded without it would leave
|
||||
-- that map standing in its own unlit shadow
|
||||
put(ViewBox.signature())
|
||||
put(tostring(terrain))
|
||||
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
||||
for _, p in ipairs(posed) do
|
||||
@@ -802,9 +811,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
if not ShadowMap.begin(cx, cy, vw, vh) then return end
|
||||
|
||||
ShadowMap.draw(terrain, atlasFor(state.map), nil)
|
||||
-- The window box's coarse cut, here and at every neighbour loop below
|
||||
-- (lib/ViewBox): a connected map lying entirely outside this frame's
|
||||
-- viewport has nothing inside it that could reach the picture, so it is
|
||||
-- not submitted at all. True for every map whenever there is no box,
|
||||
-- which is every frame the row is OFF and every headset frame.
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
-- The water surface, which the terrain mesh no longer carries (it is its
|
||||
-- own reflective pass now -- see Water). The sun still has to see it, or
|
||||
@@ -812,8 +828,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
-- far plane answers for the surface a shoreline tree's shadow falls on.
|
||||
ShadowMap.draw(water, atlasFor(state.map), nil)
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
-- flower billboards live outside the terrain mesh (they draw after the
|
||||
-- characters, pulled -- see render), but the sun still sees them: a
|
||||
@@ -824,8 +842,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
end
|
||||
-- From here down it is the CAST, marked as such in the map (see
|
||||
-- ShadowMap.sprites) so water can decline them: everything the world casts
|
||||
@@ -838,9 +858,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster))
|
||||
end)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
|
||||
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
|
||||
end)
|
||||
if ViewBox.showsMap(nb) then
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
|
||||
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
|
||||
end)
|
||||
end
|
||||
end
|
||||
for _, p in ipairs(posed) do
|
||||
local def = p.sprite.def
|
||||
@@ -914,6 +936,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
||||
local g = VoxelScene.glintStep(glint, cx, cy)
|
||||
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
|
||||
-- and the map's atmosphere, if it has one (see ForestAtmos): the haze
|
||||
-- the scene shader folds every surface into, in the hour's colour.
|
||||
-- nil for every map without an entry -- a clear day, exactly as before.
|
||||
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))
|
||||
@@ -945,6 +981,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
cx, cy = eyes.cx, eyes.cy
|
||||
end
|
||||
|
||||
-- and the ORBIT RUNGS' own viewport (lib/ViewBox): the flat screen's
|
||||
-- answer to the same question the diorama's box asks -- the map cut to
|
||||
-- the window that frames it, so a tilted world reads as a model with
|
||||
-- sides rather than a map running off every edge. Flat frames only: a
|
||||
-- headset's cut is Diorama's above, and the two must never both be live.
|
||||
--
|
||||
-- After the first-person block, so the box is centred on the camera
|
||||
-- actually in charge and opens out with a dive into a head rather than
|
||||
-- vanishing on the frame the rung changed.
|
||||
--
|
||||
-- Ahead of castShadows, deliberately: the sun draws the same neighbours
|
||||
-- the eye does (both ask ViewBox.showsMap), so a map skipped out here is
|
||||
-- skipped out there and nothing is left casting a shadow it cannot own.
|
||||
if not eyes then
|
||||
Voxel3D.cull = ViewBox.frame(cx, cy, vw, vh)
|
||||
else
|
||||
ViewBox.stop()
|
||||
end
|
||||
|
||||
-- A staged fight, seen by the VR eyes: the flat screen draws the battle
|
||||
-- SCREEN while one is up (this pass never runs), but the headset keeps
|
||||
-- looking at the world, so the world had better have the fight on it.
|
||||
@@ -975,9 +1030,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
local function drawScene()
|
||||
|
||||
Voxel3D.draw(terrain, atlasFor(state.map), nil)
|
||||
-- the window box's coarse cut, exactly as the sun pass took it: the same
|
||||
-- test on the same maps, so the light and the eye can never disagree
|
||||
-- about which neighbours are in this frame (see ViewBox.showsMap)
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
|
||||
-- Without a shadow map (headless, or a driver that could not make the
|
||||
@@ -1009,7 +1069,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
|
||||
end
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
if nbWater and nbWater[i] then
|
||||
if nbWater and nbWater[i] and ViewBox.showsMap(nb) then
|
||||
waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy) }
|
||||
end
|
||||
@@ -1128,8 +1188,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
local pull = VoxelScene.pull(lean)
|
||||
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
end
|
||||
-- flower billboards: pulled like the characters and the grass, MINUS
|
||||
-- the depth of 8 world pixels along the view (8 sin a -- the camera
|
||||
@@ -1147,11 +1209,21 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
|
||||
fpull, ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
end
|
||||
|
||||
-- The map's atmosphere -- god rays down from the invisible canopy, and
|
||||
-- whatever drifts through them (see ForestAtmos). Additive over the
|
||||
-- finished depth buffer, so the trees occlude the light and the light
|
||||
-- writes nothing; here in the prop slot, after everything the beams
|
||||
-- should fall across and inside drawScene so VR gets them per eye. On
|
||||
-- the one map that has any, today.
|
||||
ForestAtmos.draw(state.map)
|
||||
|
||||
-- The VR pokedex in the player's left hand, last of all: a prop over
|
||||
-- the world drawn with real depth, so leaning it into a wall still
|
||||
-- occludes honestly. Its frame only exists while a session is live and
|
||||
@@ -1185,12 +1257,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
|
||||
end -- drawScene
|
||||
|
||||
-- the viewport 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
|
||||
ViewBox.stop()
|
||||
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
|
||||
@@ -1205,12 +1285,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
|
||||
|
||||
+63
-3
@@ -463,6 +463,28 @@ 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 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), plus the box's two half-extents. 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;
|
||||
uniform vec2 cullRect;
|
||||
|
||||
float dioramaCull(vec3 p) {
|
||||
if (cullShape.z <= 0.5) return 1.0;
|
||||
vec3 cd = p - cullAt;
|
||||
float inside;
|
||||
if (cullShape.z < 1.5) {
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd);
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz);
|
||||
}
|
||||
return clamp(inside * 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.
|
||||
@@ -940,7 +962,16 @@ vec2 waveUV(vec2 tc, vec2 col) {
|
||||
// can afford it -- the colour is a colour, and tc/sc arrived through
|
||||
// LOVE's mediump plumbing whatever this signature says -- and the maths
|
||||
// below runs on the stage default the moment the values touch a local.
|
||||
vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
|
||||
//
|
||||
// Which precision that has to BE is not ours to know: LOVE 12 forward-
|
||||
// declares effect() under a different one, and pins that matched 11's
|
||||
// prototype are the mismatch there -- the same refusal, from the other
|
||||
// side, with the water falling back to flat. So the qualifier is a define
|
||||
// the Lua side fills in, and Water.shader compiles the pinned form first
|
||||
// and the bare one only if that is refused. Whichever prototype a runtime
|
||||
// brought, one of the two agrees with it.
|
||||
vec4 effect(EFFECT_PREC vec4 color, Image tex, EFFECT_PREC vec2 tc,
|
||||
EFFECT_PREC vec2 sc) {
|
||||
// THE DEPTH TEST, done here because the buffer that would have done it is
|
||||
// detached for the length of this pass so it can be READ (see the header).
|
||||
// Same comparison, same buffer, same result: a building in front of a pond
|
||||
@@ -1080,7 +1111,14 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
|
||||
#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
|
||||
]]
|
||||
@@ -1133,7 +1171,7 @@ end
|
||||
|
||||
Water._trainSource = trainSource -- named for the suite
|
||||
|
||||
local function source(grid)
|
||||
local function source(grid, bare)
|
||||
local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%")))
|
||||
src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%")))
|
||||
local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n"
|
||||
@@ -1141,6 +1179,12 @@ local function source(grid)
|
||||
:format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS,
|
||||
Water.WAVE_STRIDE)
|
||||
if grid then head = head .. "#define VOXEL_GRID 1\n" end
|
||||
-- effect()'s parameter precision -- see the signature for why it cannot
|
||||
-- simply be spelled there. Empty is a define all the same: the params
|
||||
-- then carry the stage default, which is what a prototype declared
|
||||
-- without qualifiers wants.
|
||||
head = head .. (bare and "#define EFFECT_PREC\n"
|
||||
or "#define EFFECT_PREC mediump\n")
|
||||
return head .. src
|
||||
end
|
||||
|
||||
@@ -1159,6 +1203,13 @@ function Water.shader(grid)
|
||||
shaders[grid] = false
|
||||
else
|
||||
local ok, sh = pcall(love.graphics.newShader, source(grid))
|
||||
if not ok then
|
||||
-- the pinned prototype was the wrong one for this runtime; the bare
|
||||
-- one is the only other shape there is, and a driver that refuses
|
||||
-- both was never going to draw this water anyway
|
||||
local bareOk, bareSh = pcall(love.graphics.newShader, source(grid, true))
|
||||
if bareOk then ok, sh = bareOk, bareSh end
|
||||
end
|
||||
if not ok and V and V.mod and V.mod.log then
|
||||
-- once, where it can be read: the fallback is flat water, which is
|
||||
-- easy to look at and impossible to diagnose without this line
|
||||
@@ -1224,6 +1275,15 @@ function Water.begin(ctx)
|
||||
send("vp", "row", ctx.vp)
|
||||
send("eye", ctx.eye)
|
||||
send("curve", ctx.curve)
|
||||
-- the viewport, as beginScene sent it to the scene shader; kind 0 --
|
||||
-- every frame neither the diorama nor the orbit's box has cut -- 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("cullRect", cull and { cull.rx or cull.r, cull.rz or cull.r }
|
||||
or { 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
|
||||
|
||||
+20
-3
@@ -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
|
||||
|
||||
@@ -85,11 +85,13 @@ local TiltShift = V.require("TiltShift")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local ViewBox = V.require("ViewBox")
|
||||
local OverworldBattle = V.require("OverworldBattle")
|
||||
local BattleExit = V.require("BattleExit")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
local ForestAtmos = V.require("ForestAtmos")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
@@ -102,6 +104,11 @@ local Horde = V.require("Horde")
|
||||
local HordeGun = V.require("HordeGun")
|
||||
local HordeHud = V.require("HordeHud")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the
|
||||
-- wraps and the experience math; CatchThrow the session (input, arc,
|
||||
-- ring, choreography); Pokeball the animated prop they throw.
|
||||
local LetsGo = V.require("LetsGo")
|
||||
local Pokeball = V.require("Pokeball")
|
||||
|
||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||
-- calls this, and it is defined further down with the settings it drives.
|
||||
@@ -191,6 +198,22 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
|
||||
-- would mid-walk
|
||||
DayNight.update(dt)
|
||||
-- the atmosphere's own clock (shaft shimmer, drifting motes), on the
|
||||
-- same tick so the beams keep breathing through a dialog box
|
||||
ForestAtmos.update(dt)
|
||||
-- LET'S GO rides the same always-running tick, and BEFORE the battle's
|
||||
-- own update on purpose: the capture session poses the Poke Ball here,
|
||||
-- and OverworldBattle.update renders the arena a moment later -- so
|
||||
-- the ball each frame draws is the ball that frame computed. Guarded,
|
||||
-- and loudly: a fault in the capture game must cost the capture game,
|
||||
-- not the whole voxel pipeline.
|
||||
do
|
||||
local okLG, errLG = pcall(LetsGo.update, dt)
|
||||
if not okLG and not V.letsGoWarned then
|
||||
V.letsGoWarned = true
|
||||
mod.log:warn("LET'S GO update failed: %s", tostring(errLG))
|
||||
end
|
||||
end
|
||||
-- The overworld battle rides this hook rather than owning a pipeline of
|
||||
-- its own, because it owns no pass of the FRAME: it draws under a battle
|
||||
-- screen the engine composites, which is not a stage the registry has.
|
||||
@@ -302,7 +325,9 @@ mod.content.render_pipelines:register("voxel", {
|
||||
OverworldBattle.invalidate()
|
||||
AntiAlias.invalidate()
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
|
||||
VR.invalidate() -- the mirror, and FBO ids of dead canvases
|
||||
Pokeball.invalidate() -- the ball's meshes and palette texture
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -368,6 +393,11 @@ applyFull = function(level)
|
||||
-- the horizon flat. The curve bends the world away from a walking player,
|
||||
-- which fights a fixed diorama framing
|
||||
WorldCurve.setting:setIndex(1, Game)
|
||||
-- and the world cut to the window it is framed in (lib/ViewBox). FULL is
|
||||
-- the model-on-a-table read and the sides are most of what makes it one:
|
||||
-- a slab of Kanto with edges, rather than a map whose corners happen to
|
||||
-- fall off the frame.
|
||||
ViewBox.setting:setIndex(1, Game)
|
||||
-- and the water reflecting everything it can: FULL is the diorama at its
|
||||
-- most photographed, and a lake with the sky and the shoreline in it is
|
||||
-- most of what makes the model read as being outdoors
|
||||
@@ -421,12 +451,44 @@ 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." },
|
||||
{ ViewBox.setting,
|
||||
"How much of the map the camera bothers to draw. FIT is exactly the "
|
||||
.. "ground on screen and no more -- the shape a tilted camera really "
|
||||
.. "frames, which reaches well north of you and flares wide out there, "
|
||||
.. "not the square the flat game shows. So a connected map that falls "
|
||||
.. "entirely outside it is skipped before it is drawn, terrain, water, "
|
||||
.. "grass and shadows together, which is most of the frame's geometry "
|
||||
.. "at the high rungs. Below about 63 degrees that is all the row does "
|
||||
.. "and the picture is untouched. At 75 the camera can see all the way "
|
||||
.. "to the horizon, so something has to name a distance: FIT is the "
|
||||
.. "closest, WIDE through WIDEST push the world's edge further out, "
|
||||
.. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are "
|
||||
.. "standing in the world there -- and the box opens out and away as "
|
||||
.. "the camera dives in." },
|
||||
{ Water.setting,
|
||||
"Reflections on water. FULL adds screen-space reflections of the "
|
||||
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
|
||||
.. "the sun and the moon alone, which is most of the look for a "
|
||||
.. "fraction of the cost." },
|
||||
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
|
||||
-- supersampling that is a question about the hardware, not the look.
|
||||
{ ForestAtmos.setting,
|
||||
"The air of the deep woods (Viridian Forest): a ground haze, and "
|
||||
.. "volumetric light let down through the unseen canopy overhead -- "
|
||||
.. "gold spears of sun by day, silver moon rays at night, pollen "
|
||||
.. "drifting through the beams and fireflies once they cool. LOW "
|
||||
.. "keeps the haze, halves the beam march and stands the particles "
|
||||
.. "down. On a phone the row offers LOW alone: the beams need a "
|
||||
.. "depth texture the pass can read back, and no mobile driver here "
|
||||
.. "grants one.",
|
||||
full = true },
|
||||
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
|
||||
-- knobs; what a battle is drawn over, and how it is framed, are not that.
|
||||
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
|
||||
@@ -456,6 +518,22 @@ local SETTINGS = {
|
||||
.. "The foe is still out there on its own tile.",
|
||||
when = function() return stagedBattles() and not VR.enabled() end,
|
||||
full = true },
|
||||
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
|
||||
-- the diorama, so the FULL preset neither sets it nor takes it away.
|
||||
{ LetsGo.setting,
|
||||
"Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, "
|
||||
.. "a finger or the right stick to throw the ball at the wild Pokemon "
|
||||
.. "-- spin it first for a curve -- and land inside the shrinking "
|
||||
.. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. "
|
||||
.. "CATCH ONLY changes nothing else: picking a ball in battle simply "
|
||||
.. "plays the throw. FULL is the whole Let's Go treatment: wild "
|
||||
.. "encounters open straight in throwing mode (B backs out to the "
|
||||
.. "classic menu), Poke/Great/Ultra Balls are half price, and a catch "
|
||||
.. "pays the whole party experience -- scaled by throw quality, first "
|
||||
.. "throws, new species and your running catch combo. Needs 3D-BTL "
|
||||
.. "on; anywhere the staged fight cannot stand, balls quietly throw "
|
||||
.. "the classic way.",
|
||||
full = true },
|
||||
{ DayNight.setting,
|
||||
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
|
||||
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
|
||||
@@ -479,9 +557,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.",
|
||||
@@ -498,7 +585,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 = {}
|
||||
@@ -584,10 +674,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")
|
||||
@@ -914,6 +1023,9 @@ mod.events:on("map.reloaded", function(payload)
|
||||
if payload and payload.reason == "colors" then return end
|
||||
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
|
||||
if mapId then ChunkMesher.invalidate(mapId) end
|
||||
-- the atmosphere's layout stands on the same carved stamps the meshes
|
||||
-- do, so it goes stale on exactly the same event
|
||||
if mapId then ForestAtmos.invalidate(mapId) end
|
||||
end)
|
||||
|
||||
-- ------- rows come and go, so the menu has to notice
|
||||
@@ -1059,6 +1171,15 @@ end
|
||||
-- become the same eight buttons. See lib/Horde.lua.
|
||||
Horde.install()
|
||||
|
||||
-- ------- LET'S GO capture mode
|
||||
--
|
||||
-- After every other input seam on purpose: while a throw is being aimed
|
||||
-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is
|
||||
-- read before anything else can claim the pointer -- and outside the aim
|
||||
-- they forward every byte untouched. The battle-side wraps (throwBall,
|
||||
-- safariAction) and the experience hooks install here too.
|
||||
LetsGo.install()
|
||||
|
||||
-- ------- edge-anchored menus stay in the GB frame while a headset is live
|
||||
--
|
||||
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.2",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
@@ -10,7 +10,7 @@
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"conflicts": ["ds_fp_ceiling"],
|
||||
"permissions": [
|
||||
"engine_internals"
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,9 +28,12 @@ CONTEXT_SLOTS = {
|
||||
165: ('idle', 'code',
|
||||
'The standby loop. func_8432B0A4 restores this slot whenever the Pokemon '
|
||||
'returns to neutral, and it resolves to animation 0 for all 151 species.'),
|
||||
166: ('hit', 'data',
|
||||
166: ('attack_default', 'data',
|
||||
'Resolves to animation 2 for 149/151 species, the same animation slots '
|
||||
'178-181 use in the reaction paths.'),
|
||||
'178-181 use in the reaction paths. Called "hit" until the move table '
|
||||
'was read against it: it is the animation most of a species\' MOVES '
|
||||
'play, the default attack rather than a damage reaction (the name has '
|
||||
'to match lib/StadiumPack.lua\'s CONTEXT and StadiumBuild\'s CONTEXTS).'),
|
||||
167: ('faint', 'data',
|
||||
'The referenced animation always ends far from the standing pose - the '
|
||||
'model collapses to 0.03-0.84x its idle height, or leaves the frame '
|
||||
|
||||
@@ -107,7 +107,7 @@ def bind_extent(data):
|
||||
|
||||
# Which context name wins when several claim the same animation. The battle
|
||||
# table points many slots at one clip, and these are the ones worth naming.
|
||||
NAME_PREF = ['idle', 'hit', 'faint', 'entrance', 'struggle', 'flinch']
|
||||
NAME_PREF = ['idle', 'attack_default', 'faint', 'entrance', 'struggle', 'flinch']
|
||||
|
||||
|
||||
def label_animations(data, rows, moves):
|
||||
@@ -294,7 +294,7 @@ def main(argv):
|
||||
moves_out = []
|
||||
if move_rows:
|
||||
default_anim = {p['species']: next(
|
||||
(a['index'] for a in p['animations'] if 'hit' in a.get('contexts', [])), -1)
|
||||
(a['index'] for a in p['animations'] if 'attack_default' in a.get('contexts', [])), -1)
|
||||
for p in manifest['pokemon']}
|
||||
for mid in range(1, battle.N_MOVES + 1):
|
||||
users, tally, ndiff = [], collections.Counter(), 0
|
||||
|
||||
@@ -315,7 +315,11 @@ class Animation:
|
||||
def __init__(self, frag, off):
|
||||
f = self.f = frag
|
||||
self.off = off
|
||||
self.flags = f.u8(off)
|
||||
# The flags live in the LOW byte of the u16 at +0 -- reading the byte
|
||||
# AT +0 gets the always-zero high byte, which silently turns every
|
||||
# hermite animation (flags & 8: Pidgeot, Dodrio, Exeggutor, Tangela,
|
||||
# Magmar) into a packed-stream read of keyframe tables.
|
||||
self.flags = f.u16(off)
|
||||
self.startFrame= f.u16(off + 4)
|
||||
self.loopStart = f.u16(off + 6)
|
||||
self.nChannels = f.u16(off + 8)
|
||||
@@ -333,16 +337,20 @@ class Animation:
|
||||
oRot=f.u16(o + 6), oTrans=f.u16(o + 8))
|
||||
|
||||
# -- packed stream sampling (flags & 8 == 0) --------------------------
|
||||
# A count of 0 means the component has no stream at all. The game's index
|
||||
# arithmetic (offset + count - 1) then runs off the front of the array -- for
|
||||
# Tangela's idle the scale "array" is two entries long and the computed index
|
||||
# is 99 -- so an empty channel is treated as "keep the bind-pose value".
|
||||
# A count of 0 means the component has no stream. In the ROM that only
|
||||
# ever happens in HERMITE animations, where a count under 2 means "the
|
||||
# offset field IS the constant value" -- no packed animation of any of the
|
||||
# 151 species carries an empty channel, so the bind-pose fallback here is
|
||||
# dead code kept as a safety net.
|
||||
def _trans_packed(self, c, frame):
|
||||
if c['nTrans'] == 0:
|
||||
return None
|
||||
bits = 16 if (self.flags & 4) else 12
|
||||
if c['nTrans'] == 1:
|
||||
return float(c['oTrans'] if (self.flags & 4) else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
|
||||
# (s16) casts both ways: func_80016848 reads the u16 offset field
|
||||
# back as a signed constant.
|
||||
return float(signed(c['oTrans'], 16) if (self.flags & 4)
|
||||
else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
|
||||
i = c['oTrans'] + min(frame, c['nTrans'] - 1)
|
||||
return float(bitfield(self.f, self.transData, i, bits))
|
||||
|
||||
@@ -404,7 +412,11 @@ class Animation:
|
||||
else:
|
||||
deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0
|
||||
deg %= 360.0
|
||||
return int(deg / 360.0 * 65536.0)
|
||||
# func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above
|
||||
# 180 degrees to its negative twin. Same binary angle either way, but
|
||||
# the packer stores i16 with clamping, so an unwrapped 350-degree
|
||||
# value would pin at 32767 (= 180 degrees) instead.
|
||||
return signed(int(deg / 360.0 * 65536.0) & 0xFFFF, 16)
|
||||
|
||||
def _scale_key(self, c, frame):
|
||||
if c['nScale'] < 2:
|
||||
@@ -439,7 +451,7 @@ class AuxAnimation:
|
||||
|
||||
def __init__(self, frag, off):
|
||||
f = self.f = frag
|
||||
self.flags = f.u8(off)
|
||||
self.flags = f.u16(off) # low byte, same layout as Animation
|
||||
self.startFrame= f.u16(off + 4)
|
||||
self.loopStart = f.u16(off + 6)
|
||||
self.nChannels = f.u16(off + 8)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
-- data/battle_arenas.lua, edited in place by line.
|
||||
--
|
||||
-- The arena editor (tests/arena_editor.lua) writes this file back every time
|
||||
-- a map is committed, and most of what is in it is PROSE: which alternatives
|
||||
-- were rejected for each area and what was wrong with them. That argument is
|
||||
-- the part a tool cannot reconstruct, so nothing here regenerates the file --
|
||||
-- an entry that changed has its own lines replaced, a new one is appended,
|
||||
-- and every other byte comes through exactly as it was.
|
||||
--
|
||||
-- Kept out of the driver so it can be tested without a game: a bug in here
|
||||
-- writes a config file, and the failure mode of a bad one is silent -- every
|
||||
-- map loses its authored spot and battles quietly start happening somewhere
|
||||
-- else. `apply` therefore never decides on its own that its output is good;
|
||||
-- it hands back the text and the caller parses it back and checks it says
|
||||
-- what was asked for before anything is overwritten.
|
||||
--
|
||||
-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
|
||||
-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
|
||||
-- shape = "narrow" } })
|
||||
--
|
||||
-- An edit's value is an entry table, `false` for an authored refusal, or
|
||||
-- Config.REMOVE to take the entry out altogether.
|
||||
local Config = {}
|
||||
|
||||
Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
|
||||
|
||||
-- The heading new maps are collected under, so a file worked over several
|
||||
-- sessions grows one list rather than one heading per session.
|
||||
Config.SECTION = " -- ------- added by tests/arena_editor"
|
||||
|
||||
-- Lossless both ways: a trailing newline becomes a trailing empty line, and
|
||||
-- concat puts it back.
|
||||
function Config.splitLines(text)
|
||||
local out, pos = {}, 1
|
||||
while true do
|
||||
local a, b = text:find("\n", pos, true)
|
||||
if not a then
|
||||
out[#out + 1] = text:sub(pos)
|
||||
break
|
||||
end
|
||||
out[#out + 1] = text:sub(pos, a - 1)
|
||||
pos = b + 1
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- One entry, laid out the way the file already lays them out: on one line
|
||||
-- where it fits inside the file's own margin, and wrapped under the brace
|
||||
-- where it does not (which is how the cross-floor entries are written).
|
||||
function Config.entryLines(id, entry)
|
||||
if entry == false then
|
||||
return { (" [%q] = false,"):format(id) }
|
||||
end
|
||||
local head = (" [%q] = { "):format(id)
|
||||
local parts = {}
|
||||
if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
|
||||
parts[#parts + 1] = ("x = %d"):format(entry.x)
|
||||
parts[#parts + 1] = ("y = %d"):format(entry.y)
|
||||
parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
|
||||
if (entry.turn or 0) ~= 0 then
|
||||
parts[#parts + 1] = ("turn = %d"):format(entry.turn)
|
||||
end
|
||||
if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
|
||||
local one = head .. table.concat(parts, ", ") .. " },"
|
||||
if #one <= 79 then return { one } end
|
||||
local first = entry.map and 3 or 2
|
||||
return {
|
||||
head .. table.concat(parts, ", ", 1, first) .. ",",
|
||||
(" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
|
||||
}
|
||||
end
|
||||
|
||||
-- Where an id's entry starts and ends, plus how many comment lines sit
|
||||
-- directly above it. The end is the first line that closes the table, so a
|
||||
-- wrapped entry is found whole rather than half-replaced.
|
||||
function Config.findEntry(lines, id)
|
||||
local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
|
||||
for i = 1, #lines do
|
||||
if lines[i]:find(pat) then
|
||||
local note = 0
|
||||
while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
|
||||
note = note + 1
|
||||
end
|
||||
if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
|
||||
for j = i, #lines do
|
||||
if lines[j]:find("}%s*,?%s*$") then return i, j, note end
|
||||
end
|
||||
return i, i, note
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The `}` that closes the returned table: new entries go in above it.
|
||||
function Config.closingLine(lines)
|
||||
for i = #lines, 1, -1 do
|
||||
if lines[i]:find("^}") then return i end
|
||||
end
|
||||
return #lines + 1
|
||||
end
|
||||
|
||||
-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
|
||||
--
|
||||
-- Returns the new text and a report: `changed`, `added`, `removed` and
|
||||
-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
|
||||
-- entries had comment lines directly above them, and a comment above an
|
||||
-- entry that has just moved describes where it USED to be.
|
||||
function Config.apply(text, edits)
|
||||
local lines = Config.splitLines(text)
|
||||
local report = { changed = {}, added = {}, removed = {}, stale = {} }
|
||||
|
||||
local ordered = {}
|
||||
for id in pairs(edits) do ordered[#ordered + 1] = id end
|
||||
table.sort(ordered)
|
||||
|
||||
local appended = {}
|
||||
for _, id in ipairs(ordered) do
|
||||
local entry = edits[id]
|
||||
-- found again per edit rather than indexed once up front: each
|
||||
-- replacement changes the line count under every index after it
|
||||
local from, to, note = Config.findEntry(lines, id)
|
||||
if entry == Config.REMOVE then
|
||||
if from then
|
||||
for _ = from, to do table.remove(lines, from) end
|
||||
report.removed[#report.removed + 1] = id
|
||||
if note > 0 then report.stale[#report.stale + 1] = id end
|
||||
end
|
||||
elseif from then
|
||||
local new = Config.entryLines(id, entry)
|
||||
for _ = from, to do table.remove(lines, from) end
|
||||
for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
|
||||
report.changed[#report.changed + 1] = id
|
||||
if note > 0 then report.stale[#report.stale + 1] = id end
|
||||
else
|
||||
appended[#appended + 1] = id
|
||||
report.added[#report.added + 1] = id
|
||||
end
|
||||
end
|
||||
|
||||
if #appended > 0 then
|
||||
local at, block = nil, {}
|
||||
for i = 1, #lines do
|
||||
if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
|
||||
at = i + 1
|
||||
end
|
||||
end
|
||||
if not at then
|
||||
at = Config.closingLine(lines)
|
||||
block[#block + 1] = ""
|
||||
block[#block + 1] = Config.SECTION
|
||||
end
|
||||
for _, id in ipairs(appended) do
|
||||
for _, l in ipairs(Config.entryLines(id, edits[id])) do
|
||||
block[#block + 1] = l
|
||||
end
|
||||
end
|
||||
for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n"), report
|
||||
end
|
||||
|
||||
-- Whether two entries say the same thing. `false` and nil are values here --
|
||||
-- a refusal is not the absence of one -- so this is an equality test and not
|
||||
-- a truthiness one.
|
||||
function Config.same(a, b)
|
||||
if a == b then return true end
|
||||
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||
return a.x == b.x and a.y == b.y
|
||||
and (a.shape or "wide") == (b.shape or "wide")
|
||||
and (a.turn or 0) == (b.turn or 0)
|
||||
and a.cam == b.cam and a.map == b.map
|
||||
end
|
||||
|
||||
-- Parse a config back and check it says what was committed. This is what
|
||||
-- stands between a bug in the surgery above and a data file that no longer
|
||||
-- loads, so it is run on the TEXT before that text replaces anything.
|
||||
-- Returns the parsed table, or nil and why not.
|
||||
function Config.verify(text, edits, chunkName)
|
||||
local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
|
||||
if not chunk then return nil, "would not compile: " .. tostring(err) end
|
||||
local ok, list = pcall(chunk)
|
||||
if not (ok and type(list) == "table") then
|
||||
return nil, "did not return a table: " .. tostring(list)
|
||||
end
|
||||
for id, want in pairs(edits) do
|
||||
local got = list[id]
|
||||
if want == Config.REMOVE then
|
||||
if got ~= nil then return nil, id .. " is still in the file" end
|
||||
elseif not Config.same(want, got) then
|
||||
return nil, id .. " did not read back as it was committed"
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
return Config
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
-- Scratch driver: the overworld cuttable tree ($2D/$2E/$3D/$3E, one
|
||||
-- cell) at PEWTER_CITY (26,4) -- it sits in a gap of the border tree
|
||||
-- wall, so shoot from the open grass east/west and the path south.
|
||||
-- Same spots BEFORE and AFTER the pin change.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cuttree_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/cuttree AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/cuttree")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[cuttree] DRAMATIC_SHAPE is not loaded")
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- prove the RUNNING mod sees the pin we think it does
|
||||
local TS = V.require("TileShape")
|
||||
local shapes = TS.forMap({ tileset = { id = "OVERWORLD",
|
||||
imageWidth = 128,
|
||||
imageHeight = 48 } })
|
||||
for _, t in ipairs({ 45, 61 }) do
|
||||
local s = shapes[t]
|
||||
print("[cuttree] running-mod OVERWORLD tile " .. t .. ": "
|
||||
.. (s and (tostring(s.class) .. "/" .. tostring(s.art)
|
||||
.. " h=" .. tostring(s.h)) or "nil"))
|
||||
end
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- open grass west of the tree, edge-on
|
||||
{ map = "PEWTER_CITY", x = 25, y = 4, face = "right", label = "cut_west" },
|
||||
-- open grass east of it
|
||||
{ map = "PEWTER_CITY", x = 27, y = 4, face = "left", label = "cut_east" },
|
||||
-- the path south, seeing its front over the tree wall gap
|
||||
{ map = "PEWTER_CITY", x = 26, y = 6, face = "up", label = "cut_front" },
|
||||
-- one step closer on the gap's south side
|
||||
{ map = "PEWTER_CITY", x = 26, y = 5, face = "up", label = "cut_near" },
|
||||
-- Cerulean's lone cut tree at (19,28): open grass to its south
|
||||
{ map = "CERULEAN_CITY", x = 19, y = 30, face = "up", label = "cer_front" },
|
||||
{ map = "CERULEAN_CITY", x = 19, y = 29, face = "up", label = "cer_near" },
|
||||
{ map = "CERULEAN_CITY", x = 20, y = 29, face = "up", label = "cer_diag" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[cuttree] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[cuttree] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[cuttree] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Scratch driver: the Celadon Diner's stools ($07/$08/$23/$24 on LOBBY,
|
||||
-- one cell each; the pinned `stool` standee pool). Shot at the voxel
|
||||
-- rung (5) and the flat rung (3) for orientation, front/back/side of
|
||||
-- the stool at cell (0,4).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diner_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR")
|
||||
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[diner] DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- prove the RUNNING mod resolves the stool tiles as pinned
|
||||
local TS = V.require("TileShape")
|
||||
local shapes = TS.forMap({ tileset = { id = "LOBBY",
|
||||
imageWidth = 128,
|
||||
imageHeight = 48 } })
|
||||
for _, t in ipairs({ 7, 8, 23, 24 }) do
|
||||
local s = shapes[t]
|
||||
print(("[diner] running-mod tile %d: %s"):format(t,
|
||||
s and (tostring(s.class) .. "/" .. tostring(s.art)
|
||||
.. " h=" .. tostring(s.height)) or "nil"))
|
||||
end
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- the stool at (0,4), seen from the south = its front (legs row)
|
||||
{ map = "CELADON_DINER", x = 0, y = 6, face = "up", label = "stool_front" },
|
||||
-- the same stool from the north = its back
|
||||
{ map = "CELADON_DINER", x = 0, y = 2, face = "down", label = "stool_back" },
|
||||
-- edge-on from the east, with the table beside it in frame
|
||||
{ map = "CELADON_DINER", x = 2, y = 4, face = "left", label = "stool_side" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[diner] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[diner] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[diner] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Driver: the DIORAMA mode's own frame, without a headset.
|
||||
--
|
||||
-- The diorama is drawn through VoxelScene's `eyes` path, and that path only
|
||||
-- ever runs from lib/VR -- so with no OpenXR runtime on the machine there is
|
||||
-- nothing to look at and nothing to check. This builds ONE eye by hand (the
|
||||
-- same VRRig mapping the headset would have built), opens a diorama frame,
|
||||
-- and encodes the eye canvas straight to a PNG.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diorama_shots.lua \
|
||||
-- SHOT_DIR=<dir> lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- SHOT_DIR output directory under the LOVE save dir (default "diorama")
|
||||
-- DIO_MAP map id (default VIRIDIAN_CITY)
|
||||
-- DIO_SPOT "x,y[,facing]" (default 20,26,up)
|
||||
-- DIO_RUNG the voxel camera rung (default 5, the 75 one)
|
||||
--
|
||||
-- It also prints whether each shader the mode touches actually COMPILED,
|
||||
-- which is the part a headless suite cannot answer: the cull is new source
|
||||
-- in the scene shader, the water shader and both forest ones, and a shader
|
||||
-- that will not build fails soft everywhere in this mod -- the picture just
|
||||
-- quietly loses a pass.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = os.getenv("SHOT_DIR") or "diorama"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[dio] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local VRRig = V.require("VRRig")
|
||||
local Diorama = V.require("Diorama")
|
||||
local Water = V.require("Water")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local DayNight = V.require("DayNight")
|
||||
local VR = V.require("VR")
|
||||
local Curve = V.require("WorldCurve")
|
||||
|
||||
local MAP = os.getenv("DIO_MAP") or "VIRIDIAN_CITY"
|
||||
local SPOT = os.getenv("DIO_SPOT") or "20,26,up"
|
||||
local RUNG = math.floor(tonumber(os.getenv("DIO_RUNG")) or 5)
|
||||
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
|
||||
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
|
||||
if sf == "" then sf = "up" end
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
DayNight.setting:sync("day")
|
||||
U.teleport(game, MAP, sx, sy, sf)
|
||||
Pipelines.setLevel("voxel", RUNG)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
end
|
||||
settle()
|
||||
settle()
|
||||
|
||||
print(("[dio] scene shader %s | grid variant %s | water %s")
|
||||
:format(tostring(Voxel3D.shader() ~= nil),
|
||||
tostring(Voxel3D.shader(true) ~= nil),
|
||||
tostring(Water.shader(false) ~= nil)))
|
||||
|
||||
-- one eye, straight ahead from the resting head: the mapping lib/VR would
|
||||
-- have built for this rung, with no pose to read off a runtime
|
||||
local POSE = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } }
|
||||
local FOV = { angleLeft = -0.8, angleRight = 0.8,
|
||||
angleUp = 0.7, angleDown = -0.7 }
|
||||
|
||||
local function shoot(name, opts)
|
||||
local ow = game.overworld
|
||||
local vw, vh = 320, 288
|
||||
pcall(function() vw, vh = game.renderer:worldViewSize() end)
|
||||
local cx = ow.camera.x + vw / 2
|
||||
local cy = ow.camera.y + vh / 2
|
||||
|
||||
Diorama.begin(opts.mode or "diorama")
|
||||
Diorama.zoom = opts.zoom or 1
|
||||
Diorama.yaw = opts.yaw or 0
|
||||
-- the cut's SHAPE is the V-CURVE row's (box while flat, ball while
|
||||
-- bent), so the driver throws the row rather than asking for a shape
|
||||
Curve.setting:sync(opts.curve or 0)
|
||||
if opts.arena then
|
||||
Diorama.pillar(opts.arena)
|
||||
else
|
||||
Diorama.viewport(cx, cy, vh)
|
||||
end
|
||||
local pivot = VRRig.dioramaPivot(Diorama.cull.x, Diorama.cull.z)
|
||||
local anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
|
||||
-- the same framing lib/VR picks: the view ordinarily, the DISC while a
|
||||
-- fight is staged
|
||||
local frame = opts.arena and (Diorama.cull.r * 2.6) or vh
|
||||
local scale = VRRig.dioramaScale(frame, Voxel.FOCAL)
|
||||
-- the bend the diorama asks its eyes for (lib/VR does the same)
|
||||
local curveK = Curve.k(vh)
|
||||
local eyes = {
|
||||
{ camera = VRRig.eyeCamera(POSE, FOV, pivot, anchor, scale,
|
||||
opts.yaw ~= 0 and opts.yaw or nil, curveK),
|
||||
w = 720, h = 720, slot = "vrL", adopt = false },
|
||||
cx = pivot[1], cy = pivot[3],
|
||||
}
|
||||
VoxelScene.spriteLean = math.rad(75)
|
||||
local ok, out = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
|
||||
VR.paletteFor, eyes)
|
||||
VoxelScene.spriteLean = nil
|
||||
if not (ok and out and out[1]) then
|
||||
print("[dio] " .. name .. ": no frame (" .. tostring(out) .. ")")
|
||||
Diorama.stop()
|
||||
return
|
||||
end
|
||||
local okE, err = pcall(function()
|
||||
love.filesystem.createDirectory(ROOT)
|
||||
local data = out[1]:newImageData()
|
||||
data:encode("png", ROOT .. "/" .. name .. ".png")
|
||||
end)
|
||||
print("[dio] " .. name
|
||||
.. (okE and " written" or (" ENCODE FAILED: " .. tostring(err))))
|
||||
Diorama.stop()
|
||||
end
|
||||
|
||||
shoot("box", { mode = "diorama" })
|
||||
shoot("box-turned", { mode = "diorama", yaw = math.rad(35) })
|
||||
shoot("box-tight", { mode = "diorama", zoom = 0.5 })
|
||||
shoot("ball-3", { mode = "diorama", curve = 3 })
|
||||
shoot("ball-4", { mode = "diorama", curve = 4 })
|
||||
shoot("ball-5", { mode = "diorama", curve = 5 })
|
||||
shoot("ball-wide", { mode = "diorama", curve = 5, zoom = 1.9 })
|
||||
shoot("keyed", { mode = "diorama-mr" })
|
||||
shoot("keyed-ball", { mode = "diorama-mr", curve = 3 })
|
||||
do
|
||||
-- a fight's disc, cut about the arena the map would actually stage on
|
||||
local BattleArena = V.require("BattleArena")
|
||||
local ow = game.overworld
|
||||
local arena = BattleArena.find(ow.map, ow.player.cellX, ow.player.cellY,
|
||||
false)
|
||||
if arena then
|
||||
shoot("arena-disc", { mode = "diorama", arena = arena })
|
||||
else
|
||||
print("[dio] no arena on this map -- disc shot skipped")
|
||||
end
|
||||
end
|
||||
|
||||
Diorama.stop()
|
||||
print("[dio] done; PNGs are under the LOVE save directory / " .. ROOT)
|
||||
love.event.quit()
|
||||
end
|
||||
+802
-23
@@ -142,6 +142,7 @@ T.check(not fullIds["pipeline:tiltshift"],
|
||||
"FULL takes T-SHIFT off the menu -- it owns the blur")
|
||||
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
|
||||
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
|
||||
T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
|
||||
T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
|
||||
|
||||
-- but the battle rows survive it: they are not knobs on the look, and FULL
|
||||
@@ -319,7 +320,8 @@ local order = {}
|
||||
for i, row in ipairs(grouped) do order[row.id] = i end
|
||||
T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
|
||||
"the mode's settings follow its pipeline rows")
|
||||
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4,
|
||||
-- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL
|
||||
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6,
|
||||
"and sit in one unbroken block, not scattered to the end of the list")
|
||||
T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
|
||||
"with the engine's own later rows still after them")
|
||||
@@ -404,18 +406,28 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
-- (nothing to store, nothing for the mod manager to persist) and is offered
|
||||
-- on every platform, saying WHERE? rather than IMPORT where there is no file
|
||||
-- dialog to open
|
||||
T.eq(#hookedRows, 10, "the options hook added a row per setting, plus the "
|
||||
T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the "
|
||||
.. "STADIUM ROM action row")
|
||||
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
|
||||
local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
|
||||
-- the AA row is hookedRows[8]; it is read in its own block below, because
|
||||
-- this chunk is one main function and has 200 local slots to spend
|
||||
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
|
||||
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
|
||||
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
|
||||
-- hookedRows[9] and AA hookedRows[11]; all four are read where they are
|
||||
-- used rather than named here, because this chunk is one main function and
|
||||
-- has 200 local slots to spend
|
||||
T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
|
||||
T.eq(hookedRows[4].value(), "FIT",
|
||||
"and defaults to FIT -- the cut IS the window the flat game already "
|
||||
.. "framed, which is the whole claim the row makes")
|
||||
T.eq(water.label, "WATER", "the water row carries its label")
|
||||
T.eq(water.value(), "FULL",
|
||||
"and defaults to FULL -- reflections are the point of having the row")
|
||||
water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
|
||||
T.eq(water.value(), "SKY",
|
||||
"stepping down drops the screen-space march and keeps the sky, sun and moon")
|
||||
T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label")
|
||||
T.eq(hookedRows[6].value(), "FULL",
|
||||
"and defaults to FULL -- it only spends anything on a map with an "
|
||||
.. "atmosphere entry, which is one forest today")
|
||||
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
|
||||
T.eq(daytime.value(), "SYNC",
|
||||
"and defaults to SYNC -- no value set follows the clock on the wall")
|
||||
@@ -434,6 +446,9 @@ T.eq(backRow.value(), "OFF",
|
||||
.. "map, so the classic slot is opt-in")
|
||||
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
|
||||
"on its own key, so it persists beside 3D-BTL rather than over it")
|
||||
T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label")
|
||||
T.eq(hookedRows[9].value(), "OFF",
|
||||
"and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
|
||||
|
||||
-- stepping writes through to the one place both rows read
|
||||
local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
|
||||
@@ -446,20 +461,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
|
||||
@@ -469,9 +483,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
|
||||
@@ -483,9 +513,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
|
||||
@@ -499,7 +527,7 @@ do
|
||||
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
|
||||
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
|
||||
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
|
||||
local aa = hookedRows[8]
|
||||
local aa = hookedRows[11]
|
||||
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
|
||||
T.eq(aa.value(), "OFF",
|
||||
"and starts off -- supersampling is a cost knob, and a mod must not spend "
|
||||
@@ -2909,6 +2937,109 @@ T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
|
||||
T.check(span(closeRig, shot.player) < span(rig, shot.player),
|
||||
"the mons render smaller on it, which is what it trades for fitting")
|
||||
|
||||
-- ------- the quarter turns
|
||||
--
|
||||
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
|
||||
-- the whole claim of the feature is that this is a fact about the GROUND and
|
||||
-- never about the shot: the footprint, the two mons and the camera turn
|
||||
-- together, so the pair land on the same two anchors at the same size and
|
||||
-- only what is behind them changes.
|
||||
--
|
||||
-- Asserted by REPROJECTING each turn through the real rig, exactly the way
|
||||
-- the unturned shot is checked above -- so a rig retune, or a sign error in
|
||||
-- the rotation, says so here rather than in a screenshot nobody takes.
|
||||
--
|
||||
-- In its own scope: the suite's main chunk sits at LuaJIT's 200-active-local
|
||||
-- ceiling, and every local below would be one more of them. The leading
|
||||
-- semicolon is the file's own convention -- without it the previous
|
||||
-- statement's `)` and this `(` parse as one call.
|
||||
;(function()
|
||||
local corner = { shot.x, shot.y }
|
||||
-- How wide a mon's own square comes out, measured ACROSS the arena's axis.
|
||||
-- `span` above offsets along world X, which is across the axis only while
|
||||
-- the arena is standing the way the mode was drawn; on the odd quarters the
|
||||
-- axis IS X, so offsetting along it would measure the square's depth --
|
||||
-- foreshortened by a camera that is looking almost straight down it -- and
|
||||
-- report a shrinking mon that is nothing of the kind.
|
||||
local function acrossSpan(cam, point, turn)
|
||||
local d = (turn % 180 == 0) and { 8, 0 } or { 0, 8 }
|
||||
local a = project(cam, { point[1] - d[1], point[2] - d[2] })
|
||||
local b = project(cam, { point[1] + d[1], point[2] + d[2] })
|
||||
return math.abs(b - a)
|
||||
end
|
||||
for _, turn in ipairs({ 0, 90, 180, 270 }) do
|
||||
BattleCam.reset()
|
||||
local a = BattleArena.at(corner[1], corner[2], "wide", turn)
|
||||
T.check(a ~= nil, ("the wide arena places at turn %d"):format(turn))
|
||||
T.eq(a.turn, turn, ("and carries the turn it was placed at (%d)"):format(turn))
|
||||
|
||||
-- the footprint swaps its reach on the odd quarters, which is the whole
|
||||
-- reason a corridor takes one way round and refuses the other
|
||||
local wantW = (turn % 180 == 0) and 3 or 6
|
||||
local wantH = (turn % 180 == 0) and 6 or 3
|
||||
T.eq(a.w, wantW, ("turn %d is %d cells across"):format(turn, wantW))
|
||||
T.eq(a.h, wantH, ("and %d deep"):format(wantH))
|
||||
|
||||
-- both mons stay inside the footprint they were turned within, and stay
|
||||
-- three cells apart -- the gap the move animations are scaled against
|
||||
T.check(a.enemyCell[1] >= a.x and a.enemyCell[1] < a.x + a.w
|
||||
and a.enemyCell[2] >= a.y and a.enemyCell[2] < a.y + a.h,
|
||||
("turn %d keeps the foe inside the footprint"):format(turn))
|
||||
T.check(a.playerCell[1] >= a.x and a.playerCell[1] < a.x + a.w
|
||||
and a.playerCell[2] >= a.y and a.playerCell[2] < a.y + a.h,
|
||||
("turn %d keeps the player inside it too"):format(turn))
|
||||
local gap = math.abs(a.enemyCell[1] - a.playerCell[1])
|
||||
+ math.abs(a.enemyCell[2] - a.playerCell[2])
|
||||
T.eq(gap, 3, ("turn %d still stands them three cells apart"):format(turn))
|
||||
|
||||
-- and which way the foe lies from the player, which is what `turn` NAMES
|
||||
local dx = a.enemyCell[1] - a.playerCell[1]
|
||||
local dy = a.enemyCell[2] - a.playerCell[2]
|
||||
local want = ({ [0] = { 0, -3 }, [90] = { 3, 0 },
|
||||
[180] = { 0, 3 }, [270] = { -3, 0 } })[turn]
|
||||
T.check(dx == want[1] and dy == want[2],
|
||||
("turn %d puts the foe %d,%d from the player: got %d,%d")
|
||||
:format(turn, want[1], want[2], dx, dy))
|
||||
|
||||
-- THE INVARIANT: the same composition, whichever way round it stands
|
||||
local r = BattleCam.rig(a, 0)
|
||||
local ppx, ppy = project(r, a.player)
|
||||
local eex, eey = project(r, a.enemy)
|
||||
T.check(ppx ~= nil and eex ~= nil,
|
||||
("turn %d keeps both marks in front of the camera"):format(turn))
|
||||
T.check(math.abs(ppx - 26) < 1 and math.abs(ppy - 96) < 1,
|
||||
("turn %d lands the player's mark on its anchor: (%.2f, %.2f)")
|
||||
:format(turn, ppx, ppy))
|
||||
T.check(math.abs(eex - 124) < 1 and math.abs(eey - 56) < 1,
|
||||
("turn %d lands the enemy's on its own: (%.2f, %.2f)")
|
||||
:format(turn, eex, eey))
|
||||
T.check(math.abs(acrossSpan(r, a.player, turn) - 64) < 4,
|
||||
("turn %d still makes the player's square a back pic wide (64px): got "
|
||||
.. "%.2f"):format(turn, acrossSpan(r, a.player, turn)))
|
||||
T.check(math.abs(acrossSpan(r, a.enemy, turn) - 56) < 4,
|
||||
("turn %d still makes the foe's square a front pic wide (56px): got "
|
||||
.. "%.2f"):format(turn, acrossSpan(r, a.enemy, turn)))
|
||||
|
||||
-- the eye really did move: a turn that left the camera where it was would
|
||||
-- pass every anchor test above by looking at the pair from the side
|
||||
if turn ~= 0 then
|
||||
local base = BattleArena.at(corner[1], corner[2], "wide", 0)
|
||||
BattleCam.reset()
|
||||
local r0 = BattleCam.rig(base, 0)
|
||||
local moved = math.abs(r.eye[1] - r0.eye[1])
|
||||
+ math.abs(r.eye[3] - r0.eye[3])
|
||||
T.check(moved > 16,
|
||||
("turn %d actually moves the camera (%.1f world px)"):format(turn, moved))
|
||||
end
|
||||
end
|
||||
|
||||
-- degrees in, degrees out, and anything else folded onto the four
|
||||
T.eq(BattleArena.quarters(360), 0, "a full turn is no turn")
|
||||
T.eq(BattleArena.quarters(-90), 3, "and a negative one comes round the back")
|
||||
T.eq(BattleArena.at(1, 1, "wide").turn, 0,
|
||||
"an arena placed without a turn is the way the mode was drawn")
|
||||
end)()
|
||||
|
||||
-- an arena picks its rig by name, and anything unnamed gets the default
|
||||
T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide,
|
||||
"an arena that asks for the wide lens gets it")
|
||||
@@ -3961,6 +4092,92 @@ T.check(not DayNight.isCanopy({ id = "PALLET_TOWN" }),
|
||||
T.check(not DayNight.isCanopy(nil), "no map, no canopy")
|
||||
end
|
||||
|
||||
-- ------- the air under the canopy
|
||||
--
|
||||
-- ForestAtmos hangs an INVISIBLE canopy above the forest's real trees and
|
||||
-- lets light down through it: fog for the scene shader, a volumetric
|
||||
-- march for the beams (GPU-only, not checkable here), colour and strength
|
||||
-- following the clock. Everything checkable without a GPU is checked:
|
||||
-- the authored table, the hour's ramp, and the seeded particle deal --
|
||||
-- which must come out identical on every visit.
|
||||
do
|
||||
local ForestAtmos =
|
||||
run.loader.exports.DRAMATIC_SHAPE.lib.require("ForestAtmos")
|
||||
local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
|
||||
|
||||
-- the authored table
|
||||
local cfg = ForestAtmos.configFor("VIRIDIAN_FOREST")
|
||||
T.check(cfg ~= nil, "Viridian Forest has an atmosphere entry")
|
||||
T.check(cfg and (cfg.canopyY or 0) > 32,
|
||||
"whose invisible canopy hangs ABOVE the carved tree hulls (y = 32) -- "
|
||||
.. "a ray is alpha zero at the canopy and only fades in below it")
|
||||
T.check(ForestAtmos.configFor("PALLET_TOWN") == nil,
|
||||
"a map without an entry has no atmosphere at all")
|
||||
T.check(ForestAtmos.configFor(nil) == nil, "and no map id does not crash")
|
||||
|
||||
-- the hour's ramp: gold spears by day, silver rays by night, both dying
|
||||
-- back through the twilight handover
|
||||
local fmap = { id = "VIRIDIAN_FOREST" }
|
||||
local day = ForestAtmos.frame(fmap, DayNight.T.day)
|
||||
local night = ForestAtmos.frame(fmap, DayNight.T.night)
|
||||
local dusk = ForestAtmos.frame(fmap, DayNight.T.dusk)
|
||||
T.check(day and night and dusk, "the forest answers at every pin")
|
||||
T.check(day.rayColor[1] > day.rayColor[3],
|
||||
"the day's rays are sun-gold: more red than blue")
|
||||
T.check(night.rayColor[3] > night.rayColor[1],
|
||||
"the night's are moon-silver: more blue than red")
|
||||
T.check(dusk.rayAlpha < day.rayAlpha and dusk.rayAlpha < night.rayAlpha,
|
||||
"and the twilight is the dim handover between the two")
|
||||
T.check(day.fog.density > 0 and night.fog.density > 0,
|
||||
"the haze never lifts entirely, day or night")
|
||||
T.check(day.moteLevel > 0.5 and day.fireflyLevel < 0.05,
|
||||
"pollen drifts through the day's beams, with no fireflies out")
|
||||
T.check(night.fireflyLevel > 0.5 and night.moteLevel < 0.05,
|
||||
"and the night shift trades them")
|
||||
|
||||
-- the seeded deal: the same particles on every visit (the beams place
|
||||
-- themselves -- they are marched from the shadow map, not dealt)
|
||||
local tcfg = { canopyY = 56, fadeTo = 28, seed = 77,
|
||||
motes = { count = 12 }, fireflies = { count = 6 } }
|
||||
local a = ForestAtmos.layout(tcfg, 320, 320)
|
||||
local b = ForestAtmos.layout(tcfg, 320, 320)
|
||||
T.eq(#a.motes, 12, "the pollen musters at authored strength")
|
||||
T.eq(#a.flies, 6, "the fireflies too")
|
||||
local same = true
|
||||
for i = 1, #a.motes do
|
||||
local m1, m2 = a.motes[i], b.motes[i]
|
||||
same = same and m1.x == m2.x and m1.y == m2.y and m1.z == m2.z
|
||||
end
|
||||
T.check(same, "and the deal comes out identical on every visit")
|
||||
local under = true
|
||||
for _, m in ipairs(a.motes) do
|
||||
if m.y >= tcfg.canopyY then under = false end
|
||||
end
|
||||
T.check(under, "everything drifts BELOW the canopy it lives under")
|
||||
|
||||
-- the tuning override, the same handle arena_editor holds on battles
|
||||
ForestAtmos.setOverride("PALLET_TOWN", { canopyY = 40, fog = {} })
|
||||
T.check(ForestAtmos.configFor("PALLET_TOWN") ~= nil,
|
||||
"an override stages an atmosphere a driver can tune live")
|
||||
ForestAtmos.setOverride("VIRIDIAN_FOREST", false)
|
||||
T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") == nil,
|
||||
"false is meaningful: this map has none, whatever the file says")
|
||||
ForestAtmos.setOverride("PALLET_TOWN", nil)
|
||||
ForestAtmos.setOverride("VIRIDIAN_FOREST", nil)
|
||||
T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") ~= nil
|
||||
and ForestAtmos.configFor("PALLET_TOWN") == nil,
|
||||
"and nil hands both back to the authored table")
|
||||
|
||||
-- the row: OFF is the ladder's last rung and the frame answers nothing
|
||||
T.eq(ForestAtmos.setting.values[1], "full",
|
||||
"the atmosphere defaults to FULL -- it costs a handful of quads and "
|
||||
.. "exists on one map")
|
||||
ForestAtmos.setting:sync("off")
|
||||
T.check(ForestAtmos.frame(fmap, DayNight.T.day) == nil,
|
||||
"OFF answers no frame at all: no fog uniform, no draw, no spend")
|
||||
ForestAtmos.setting:sync("full")
|
||||
end
|
||||
|
||||
-- ------- a shadow keeps hold of the feet that throw it
|
||||
--
|
||||
-- The depth compare forgives `slack` world pixels so lit ground does not
|
||||
@@ -4820,6 +5037,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 },
|
||||
@@ -5012,6 +5291,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
|
||||
@@ -5581,11 +6041,330 @@ for _, dex in ipairs({ 25, 6, 95, 143 }) do
|
||||
end
|
||||
end
|
||||
|
||||
-- the three species whose standby loop is corrupt in the source extraction
|
||||
-- are marked to hold their bind pose instead of coming apart
|
||||
T.eq(Pack.load(126).staticPose, true,
|
||||
"Magmar is held at its bind pose -- its source animations are broken")
|
||||
T.eq(pikachu.staticPose, false, "and a species with good data is not")
|
||||
-- No species is held at its bind pose any more. Magmar (with Pidgeot,
|
||||
-- Dodrio, Exeggutor and Tangela) uses the game's hermite-keyframe animation
|
||||
-- mode, which the extractor used to misread as packed streams -- the flags
|
||||
-- byte was read at +0, the always-zero high half of the u16 -- and the
|
||||
-- packer then declared their exploding standby loops corrupt. The broken-
|
||||
-- idle detector stays as the guard, so this asserts it no longer fires.
|
||||
T.eq(Pack.load(126).staticPose, false,
|
||||
"Magmar animates -- its hermite animations decode correctly now")
|
||||
T.eq(pikachu.staticPose, false, "and a packed-stream species does too")
|
||||
end)()
|
||||
|
||||
-- ------- RENDER DIST: the orbit rungs cut to the window that frames them
|
||||
--
|
||||
-- The flat screen's answer to the same question the headset's diorama box
|
||||
-- asks. Two halves, and this asserts both: the CUT the shader takes (a
|
||||
-- rectangle, because a window is not square), and the SKIP it buys -- a
|
||||
-- connected map lying entirely outside the box is never submitted.
|
||||
;(function()
|
||||
|
||||
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
|
||||
local VB = lib.require("ViewBox")
|
||||
local Dio = lib.require("Diorama")
|
||||
local VS = lib.require("VoxelState")
|
||||
local Curve = lib.require("WorldCurve")
|
||||
local boxWas, curveWas = VB.setting:get(), Curve.setting:get()
|
||||
local angleWas = VS.angle
|
||||
Curve.setting:sync(0)
|
||||
|
||||
-- ------- the ladder
|
||||
T.eq(VB.setting.values[1], 0,
|
||||
"FIT is rung 0, so an unset save lands on the cut rather than off it")
|
||||
T.eq(VB.setting.labels[1], "FIT", "and FIT is what the row calls it")
|
||||
T.eq(VB.setting.labels[#VB.setting.labels], "OFF",
|
||||
"OFF is the TOP of the ladder -- 'how much world' ending in all of it, "
|
||||
.. "rather than a switch beside the sizes")
|
||||
for i = 2, #VB.FRACS - 1 do
|
||||
T.check(VB.FRACS[i] > VB.FRACS[i - 1],
|
||||
"the ladder opens out at rung " .. (i - 1))
|
||||
end
|
||||
T.eq(VB.FRACS[1], 1.0,
|
||||
"and FIT is exactly one window: the cut is the framing the flat game "
|
||||
.. "already had, not one this file made up")
|
||||
|
||||
-- ------- which rungs it is about
|
||||
VB.setting:sync(0)
|
||||
T.eq(VB.appliesTo(0), false, "OFF has no 3D pass to cut")
|
||||
T.check(VB.appliesTo(VS.FULL_LEVEL), "FULL is cut -- it IS the model read")
|
||||
T.check(VB.appliesTo(2) and VB.appliesTo(3) and VB.appliesTo(4)
|
||||
and VB.appliesTo(5),
|
||||
"and so is every orbit rung: 15, 35, 50 and 75")
|
||||
T.eq(VB.appliesTo(VS.FP_LEVEL), false,
|
||||
"1ST is not: the player is standing IN the world, and a box round a "
|
||||
.. "walking eye is a fog-of-war circle rather than a model on a table")
|
||||
T.eq(VB.appliesTo(VS.TP_LEVEL), false, "nor 3RD, which is the same rig")
|
||||
|
||||
-- ------- the footprint IS NOT the window
|
||||
--
|
||||
-- The bug this replaced: the box was the flat game's own vw-by-vh
|
||||
-- rectangle, which is the ground a camera frames at 0 degrees and at no
|
||||
-- rung the mode actually has. Every tilted rung frames a TRAPEZOID that
|
||||
-- reaches further north and flares wider out there, and a window-sized box
|
||||
-- cut the top and both sides off a world plainly on screen -- gaps, with
|
||||
-- sky showing through them.
|
||||
--
|
||||
-- So the footprint is checked against a RAY CAST through the orbit's own
|
||||
-- basis: the frame's corner rays dropped on the ground plane. If the closed
|
||||
-- form and the rays ever disagree, the picture has a hole in it.
|
||||
local REACH = VB.MAX_REACH * 288
|
||||
local function castFootprint(a)
|
||||
local tanY = 1 / (2 * VS.FOCAL)
|
||||
local tanX = tanY * (320 / 288)
|
||||
local k = VS.FOCAL * 288
|
||||
local ca, sa = math.cos(a), math.sin(a)
|
||||
local n, s, x = -1e18, -1e18, 0
|
||||
for i = 0, 200 do
|
||||
local sy = -1 + 2 * i / 200
|
||||
-- forward (0,-ca,-sa), true up (0,sa,-ca), right (1,0,0)
|
||||
local dy = -ca + sy * tanY * sa
|
||||
local dz = -sa - sy * tanY * ca
|
||||
if dy < -1e-9 then
|
||||
local t = (k * ca) / -dy -- eye height over the drop
|
||||
local wz, wx = k * sa + t * dz, t * tanX
|
||||
if -wz > n then n = -wz end
|
||||
if wz > s then s = wz end
|
||||
if wx > x then x = wx end
|
||||
end
|
||||
end
|
||||
return n, s, x
|
||||
end
|
||||
for _, deg in ipairs({ 0, 15, 35, 50 }) do
|
||||
local n, s, x = castFootprint(math.rad(deg))
|
||||
local fn, fs, fx = VB.footprint(math.rad(deg), 320, 288, REACH)
|
||||
T.check(math.abs(fn - n) < 1 and math.abs(fs - s) < 1
|
||||
and math.abs(fx - x) < 1,
|
||||
("%d degrees: the footprint is the ground the rays actually land on "
|
||||
.. "(north %.0f/%.0f, south %.0f/%.0f, side %.0f/%.0f)")
|
||||
:format(deg, fn, n, fs, s, fx, x))
|
||||
end
|
||||
-- straight down is the ONE case the old window-sized box got right, which
|
||||
-- is why it survived being wrong everywhere else
|
||||
do
|
||||
local n, s, x = VB.footprint(0, 320, 288, REACH)
|
||||
T.check(math.abs(n - 144) < 1e-6 and math.abs(s - 144) < 1e-6
|
||||
and math.abs(x - 160) < 1e-6,
|
||||
"at 0 degrees it reduces to the flat window, exactly")
|
||||
end
|
||||
-- and the shape of the error the gaps were: north grows with the tilt,
|
||||
-- south SHRINKS (the bottom of the frame is nearer than the focus), and
|
||||
-- the sides flare with the far field
|
||||
do
|
||||
local n15, s15, x15 = VB.footprint(math.rad(15), 320, 288, REACH)
|
||||
local n50, s50, x50 = VB.footprint(math.rad(50), 320, 288, REACH)
|
||||
T.check(n15 > 144 and n50 > n15,
|
||||
"every tilted rung reaches further north than the window did -- which "
|
||||
.. "is the gap that used to open along the top")
|
||||
T.check(s15 < 144,
|
||||
"and less far south, because the bottom of the frame is nearer the eye "
|
||||
.. "than the focus is")
|
||||
T.check(x15 > 160 and x50 > x15,
|
||||
"and wider at the sides, because the far field is further away -- the "
|
||||
.. "other gap")
|
||||
T.check(s50 < n50 * 0.5,
|
||||
"so the picture is mostly AHEAD of the view centre, and the box has to "
|
||||
.. "sit north of it rather than around it")
|
||||
end
|
||||
-- past atan(2*FOCAL) -- about 63 degrees -- the horizon is inside the frame
|
||||
-- and the honest answer is infinite. That is the whole reason there is a
|
||||
-- row: something has to name a distance.
|
||||
do
|
||||
local n75 = VB.footprint(math.rad(75), 320, 288, REACH)
|
||||
T.check(math.abs(n75 - REACH) < 1,
|
||||
"at 75 the footprint is unbounded and the reach stands in for it")
|
||||
local near = VB.footprint(math.rad(75), 320, 288, REACH / 2)
|
||||
T.check(near < n75, "and a shorter reach brings the world's edge in")
|
||||
end
|
||||
|
||||
-- ------- the cut itself
|
||||
VS.angle = math.rad(35)
|
||||
local box = VB.frame(100, 200, 320, 288, 3)
|
||||
T.eq(box.kind, Dio.BOX, "the cut is the shader's BOX kind, like the diorama's")
|
||||
T.eq(box.x, 100, "centred on the view in x, which the trapezoid is")
|
||||
T.check(box.z < 200,
|
||||
"and NORTH of it in z, which the trapezoid also is -- a box centred on "
|
||||
.. "the view centre is the bug that cut the top off")
|
||||
do
|
||||
local n, s, x = VB.footprint(math.rad(35), 320, 288, REACH)
|
||||
T.check(math.abs(box.rz - (n + s) * 0.5) < 1e-6,
|
||||
"the half-depth spans the footprint from its south edge to its north")
|
||||
T.check(math.abs(box.z - (200 - (n - s) * 0.5)) < 1e-6,
|
||||
"and the centre sits exactly between them")
|
||||
T.check(math.abs(box.rx - x) < 1e-6, "with the far field's own half-width")
|
||||
T.check(box.rz > 144 and box.rx > 160,
|
||||
"both bigger than the window at every tilted rung, so the cut can "
|
||||
.. "never take a bite out of the picture")
|
||||
end
|
||||
T.check(box.rx ~= box.rz,
|
||||
"which is the whole reason the box kind carries two half-extents: a "
|
||||
.. "square cut on a wide frame either loses the sides or overshoots")
|
||||
T.check(1 / box.invFade <= 0.5,
|
||||
"with a HARD edge while the world is flat: that IS the sides")
|
||||
-- and the diorama's own box still passes the same number twice, so one
|
||||
-- shader branch serves both
|
||||
local sq = Dio.viewport(0, 0, 288)
|
||||
T.eq(sq.rx, sq.r, "a headset's box is square in x")
|
||||
T.eq(sq.rz, sq.r, "and in z -- no window to be shaped like")
|
||||
|
||||
-- the row opens it out, and OFF stops cutting entirely
|
||||
VB.setting:sync(1)
|
||||
local wide = VB.frame(100, 200, 320, 288, 3)
|
||||
T.check(wide.rx > box.rx and wide.rz > box.rz,
|
||||
"a wider rung reaches further in both directions")
|
||||
VB.setting:sync(#VB.setting.values - 1)
|
||||
T.eq(VB.setting:get(), 4, "the top rung is OFF")
|
||||
T.eq(VB.frame(100, 200, 320, 288, 3), nil, "and OFF cuts nothing at all")
|
||||
T.eq(VB.cull, nil, "leaving no viewport on the module for a pass to inherit")
|
||||
|
||||
-- a bent world has no straight sides, so the rim dissolves under V-CURVE --
|
||||
-- the same call Diorama.fadeFor makes for the same reason
|
||||
VB.setting:sync(0)
|
||||
Curve.setting:sync(3)
|
||||
local bent = VB.frame(0, 0, 320, 288, 3)
|
||||
T.check(1 / bent.invFade > 1,
|
||||
"V-CURVE softens the box's rim: a hard edge across a bent world is a "
|
||||
.. "lie about what is being looked at")
|
||||
Curve.setting:sync(0)
|
||||
|
||||
-- ------- the coarse half: whole maps skipped before they are drawn
|
||||
VB.setting:sync(0)
|
||||
VS.angle = math.rad(35)
|
||||
local cut = VB.frame(0, 0, 320, 288, 3)
|
||||
-- a 10x9-block map (Pallet's shape) at the origin is under the box
|
||||
local here = { map = { def = { width = 10, height = 9 } }, ox = 0, oy = 0 }
|
||||
T.check(VB.showsMap(here), "the map under the box is drawn")
|
||||
-- the same map pushed a long way north-east cannot reach the picture
|
||||
local far = { map = { def = { width = 10, height = 9 } },
|
||||
ox = 4000, oy = 4000 }
|
||||
T.eq(VB.showsMap(far), false,
|
||||
"a connected map entirely outside the box is skipped -- no terrain, no "
|
||||
.. "water, no grass, no flowers and no shadow pass")
|
||||
-- the test is taken against the CUT's own edge, which is the trapezoid's
|
||||
-- and not the window's -- the border ring is meshed outside a map's own
|
||||
-- rectangle, so a map that stops just short still has trees in frame
|
||||
T.check(VB.showsMap({ map = { def = { width = 1, height = 1 } },
|
||||
ox = cut.x + cut.rx + VB.PAD - 8, oy = cut.z }),
|
||||
"a map just outside the edge is kept for its border ring, which is "
|
||||
.. "meshed beyond its own rectangle")
|
||||
T.eq(VB.showsMap({ map = { def = { width = 1, height = 1 } },
|
||||
ox = cut.x + cut.rx + VB.PAD + 64, oy = cut.z }), false,
|
||||
"and dropped once even the ring cannot reach")
|
||||
-- and the skip follows the trapezoid north: a map the OLD window-sized box
|
||||
-- would have thrown away is still on screen at a tilted rung
|
||||
T.check(VB.showsMap({ map = { def = { width = 4, height = 4 } },
|
||||
ox = -64, oy = -200 }),
|
||||
"a map north of the view centre, past where a window-sized box ended, "
|
||||
.. "is kept -- the camera is looking straight at it")
|
||||
VB.stop()
|
||||
T.check(VB.showsMap(far),
|
||||
"with no box open every map is drawn, so a caller may guard "
|
||||
.. "unconditionally -- which is every headset frame and every OFF rung")
|
||||
T.check(VB.showsMap(nil), "and a malformed neighbour is never skipped")
|
||||
|
||||
-- ------- the sun has to notice the row
|
||||
--
|
||||
-- WHICH neighbours went into the shadow map is now a function of the box,
|
||||
-- and the box is the one input the sun's signature did not already carry.
|
||||
VB.setting:sync(0)
|
||||
local sigFit = VB.signature()
|
||||
VB.setting:sync(2)
|
||||
T.neq(VB.signature(), sigFit,
|
||||
"opening the row out changes the sun's signature, so a map recorded "
|
||||
.. "without a neighbour is redrawn with it")
|
||||
|
||||
Curve.setting:sync(curveWas)
|
||||
VB.setting:sync(boxWas)
|
||||
VS.angle = angleWas
|
||||
end)()
|
||||
|
||||
-- ------- LET'S GO: the capture mode's arithmetic and the ball's pose
|
||||
--
|
||||
-- The throw itself is a rendered, pointer-driven thing the suite cannot
|
||||
-- fly, but everything consequential under it is plain arithmetic and runs
|
||||
-- headless: the scaled experience formula (checked against the published
|
||||
-- Let's Go table), the combo ladder, and the Pokeball prop's state
|
||||
-- machine, which must load and animate without a GPU.
|
||||
;(function()
|
||||
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
local Pokeball = lib.require("Pokeball")
|
||||
local Mat4 = lib.require("Mat4")
|
||||
|
||||
-- the reference points from the community-measured table: a Lv30
|
||||
-- Chansey (base exp 395) pays 2371 to a Lv30 receiver and 5497 to a
|
||||
-- Lv10 one, before any catch bonuses
|
||||
T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30,
|
||||
mon = { level = 30 } }, 1), 2371,
|
||||
"the scaled formula at equal levels is b*L/5 + 1 -- the table's 2371")
|
||||
T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30,
|
||||
mon = { level = 10 } }, 1), 5497,
|
||||
"and a Lv10 receiver pulls the table's 5497 from the same catch -- "
|
||||
.. "the receiver's own level is in the denominator")
|
||||
T.check(LetsGo._scaledGain({ defeatedDef = { baseExp = 40 }, level = 3,
|
||||
mon = { level = 100 } }, 1) >= 1,
|
||||
"a trivial catch still pays at least one point")
|
||||
|
||||
-- and the same formula pays TRAINER knockouts under FULL, with the
|
||||
-- wild/trainer 1.5 that a catch never sees (a caught Pokemon is always
|
||||
-- wild, which is why the catch numbers above are untouched by it)
|
||||
local wildKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
|
||||
level = 30, mon = { level = 30 } }, 1)
|
||||
local trainerKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
|
||||
level = 30, mon = { level = 30 },
|
||||
isTrainer = true }, 1)
|
||||
T.eq(wildKO, 2371, "a wild payout is unchanged by the trainer term")
|
||||
T.eq(trainerKO, 3556, "a trainer's Pokemon pays the same formula at 1.5")
|
||||
T.check(trainerKO > wildKO, "which is more than the same wild one")
|
||||
-- the receiver's own level still drives it, which is the whole point of
|
||||
-- sharing to the party: the low member gains multiples of the high one
|
||||
local lowKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
|
||||
level = 30, mon = { level = 5 },
|
||||
isTrainer = true }, 1)
|
||||
T.check(lowKO > trainerKO * 2,
|
||||
"a level 5 party member takes several times what a level 30 one does "
|
||||
.. "from the very same knockout")
|
||||
|
||||
-- the combo ladder's five tiers
|
||||
T.eq(LetsGo._comboMult(1), 1.1, "a fresh combo is the 1.1 tier")
|
||||
T.eq(LetsGo._comboMult(10), 1.1, "which runs to ten")
|
||||
T.eq(LetsGo._comboMult(11), 1.5, "eleven starts the 1.5 tier")
|
||||
T.eq(LetsGo._comboMult(21), 2.0, "twenty-one the 2.0")
|
||||
T.eq(LetsGo._comboMult(31), 2.5, "thirty-one the 2.5")
|
||||
T.eq(LetsGo._comboMult(41), 3.0, "and forty-one caps it at 3.0")
|
||||
T.eq(LetsGo._comboMult(999), 3.0, "where it stays")
|
||||
|
||||
-- the row answers the mode, and OFF answers false
|
||||
T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF")
|
||||
|
||||
-- the ball: a full open-drop-rock-click life without a GPU
|
||||
T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on")
|
||||
do
|
||||
local m = Mat4.rotateZ(math.pi / 2)
|
||||
-- row-major: x' of (1,0,0) is m[1], y' is m[5]
|
||||
T.check(math.abs(m[1]) < 1e-9 and math.abs(m[5] - 1) < 1e-9,
|
||||
"rotateZ carries +X onto +Y, the right-handed roll")
|
||||
end
|
||||
local ball = Pokeball.new("GREAT_BALL")
|
||||
T.eq(ball.lid, 0, "a fresh ball is shut")
|
||||
ball:open()
|
||||
for _ = 1, 60 do ball:update(1 / 60) end
|
||||
T.eq(ball.lid, 1, "open() hinges the lid fully over its ramp")
|
||||
T.check(ball.glow < 1, "and the mouth glow decays on its own")
|
||||
ball:close()
|
||||
for _ = 1, 60 do ball:update(1 / 60) end
|
||||
T.eq(ball.lid, 0, "close() brings it back")
|
||||
local dur = ball:rock(1)
|
||||
T.check(dur > 0, "a rock reports its duration for the caller's cadence")
|
||||
T.check(ball:busy(), "and the ball is busy while it rocks")
|
||||
for _ = 1, math.ceil(dur * 60) + 2 do ball:update(1 / 60) end
|
||||
T.check(not ball:busy(), "then settles")
|
||||
ball:catchClick()
|
||||
T.check(ball.stars ~= nil, "the caught click spawns its stars")
|
||||
local matrix = ball:matrix()
|
||||
T.eq(#matrix, 16, "the pose is one model matrix")
|
||||
for _ = 1, 120 do ball:update(1 / 60) end
|
||||
T.check(ball.stars == nil, "and the stars burn out on their own")
|
||||
end)()
|
||||
|
||||
Pipelines.reset()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Driver: screenshot the forest's atmosphere.
|
||||
--
|
||||
-- Viridian Forest through the pins: gold shafts and drifting pollen by
|
||||
-- day, silver moon rays and fireflies at night, the haze under both, one
|
||||
-- first-person frame standing inside a beam, and the control shots -- the
|
||||
-- row OFF (which must be pixel-identical to a run before the feature
|
||||
-- existed) and LOW (haze, half the shafts, no particles).
|
||||
--
|
||||
-- Deterministic on purpose: encounters killed, tile animation frozen, the
|
||||
-- atmosphere's own clock pinned, so an AB_TAG=before/after pair diffs
|
||||
-- clean (see tools/voxel-survey.md for the workflow).
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/forestfog AB_TAG=after \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/forest_fog_shots.lua lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/forestfog")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local handle = exports and exports.DRAMATIC_SHAPE
|
||||
if not (handle and handle.lib) then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local lib = handle.lib
|
||||
local DayNight = lib.require("DayNight")
|
||||
local ChunkMesher = lib.require("ChunkMesher")
|
||||
local Voxel = lib.require("VoxelState")
|
||||
local ForestAtmos = lib.require("ForestAtmos")
|
||||
|
||||
if not (game.data.maps and game.data.maps.VIRIDIAN_FOREST) then
|
||||
U.log("no VIRIDIAN_FOREST in this dataset")
|
||||
return
|
||||
end
|
||||
|
||||
-- ------- the determinism recipe (see round_regress_shots)
|
||||
require("src.world.OverworldController").rollEncounter =
|
||||
function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
-- pin the atmosphere's own clock: every shimmer and every mote is a
|
||||
-- pure function of this number
|
||||
ForestAtmos.frozen = true
|
||||
ForestAtmos.time = 5
|
||||
|
||||
local function setTime(value)
|
||||
DayNight.setting:sync(value)
|
||||
DayNight.update(0)
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local function go(x, y, face, rung)
|
||||
U.teleport(game, "VIRIDIAN_FOREST", x, y, face or "up")
|
||||
Pipelines.setLevel("voxel", rung or 5)
|
||||
Pipelines.setLevel("tiltshift", 0) -- judge the atmosphere, not the blur
|
||||
settle()
|
||||
end
|
||||
|
||||
ForestAtmos.setting:sync("full")
|
||||
|
||||
-- ------- day: gold spears and pollen, three vantages
|
||||
setTime("day")
|
||||
go(17, 20)
|
||||
U.shot(game, ROOT .. "/10_day_17_20.png")
|
||||
go(16, 20)
|
||||
U.shot(game, ROOT .. "/11_day_16_20.png")
|
||||
go(16, 24)
|
||||
U.shot(game, ROOT .. "/12_day_16_24.png")
|
||||
|
||||
-- ------- night: moon rays and fireflies, same vantages
|
||||
setTime("night")
|
||||
go(17, 20)
|
||||
U.shot(game, ROOT .. "/20_night_17_20.png")
|
||||
go(16, 20)
|
||||
U.shot(game, ROOT .. "/21_night_16_20.png")
|
||||
go(16, 24)
|
||||
U.shot(game, ROOT .. "/22_night_16_24.png")
|
||||
|
||||
-- ------- first person, standing in a beam
|
||||
--
|
||||
-- The shafts are dealt by seed, so ask the layout where one landed and
|
||||
-- walk into it rather than guessing a cell.
|
||||
local shaft
|
||||
pcall(function()
|
||||
local map = game.overworld and game.overworld.map
|
||||
local L = map and ForestAtmos.layoutFor(map)
|
||||
shaft = L and L.shafts and L.shafts[1]
|
||||
end)
|
||||
if shaft then
|
||||
local cx = math.floor(shaft.x / 16)
|
||||
local cy = math.floor(shaft.z / 16)
|
||||
setTime("day")
|
||||
go(cx, cy + 1, "up", 6) -- the 1ST rung, facing the beam
|
||||
U.shot(game, ROOT .. "/30_fp_in_beam_day.png")
|
||||
setTime("night")
|
||||
U.wait(30)
|
||||
U.shot(game, ROOT .. "/31_fp_in_beam_night.png")
|
||||
else
|
||||
U.log("no shaft landed -- check the layout seed")
|
||||
end
|
||||
|
||||
-- ------- the controls
|
||||
setTime("day")
|
||||
ForestAtmos.setting:sync("low")
|
||||
go(17, 20)
|
||||
U.shot(game, ROOT .. "/40_low_day.png")
|
||||
ForestAtmos.setting:sync("off")
|
||||
go(17, 20)
|
||||
U.shot(game, ROOT .. "/41_off_day.png") -- must match a pre-feature run
|
||||
|
||||
ForestAtmos.setting:sync("full")
|
||||
ForestAtmos.frozen = false
|
||||
setTime("day")
|
||||
U.log("done -- " .. ROOT)
|
||||
end
|
||||
@@ -0,0 +1,309 @@
|
||||
-- Driver: photograph the LET'S GO capture mode end to end.
|
||||
--
|
||||
-- One wild encounter under FULL: the battle menu should never be seen --
|
||||
-- capture mode opens over it with the ball hanging at the player's empty
|
||||
-- cell and the ring pulsing on the foe -- then a synthetic mouse flick
|
||||
-- throws the ball and the beats that follow are photographed: flight,
|
||||
-- the open-mouth suck, the drop-and-wobble, and the outcome.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/letsgo \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_shots.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- SHOT_DIR must already exist. DS_LETSGO_MODE=catching runs the same
|
||||
-- beats through the bag-menu route instead of the FULL auto-entry.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
||||
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
local CatchThrow = lib.require("CatchThrow")
|
||||
local BattleScene = lib.require("BattleScene")
|
||||
|
||||
-- a spread of levels, because the Let's Go award pays every party
|
||||
-- member against its OWN level: a low one should pull far more from
|
||||
-- the same catch than a high one, and a flat payout would hide that
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
Pokemon.new(game.data, "RATTATA", 5),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
Bag.add(game.save, "POKE_BALL", 20, game.data)
|
||||
Bag.add(game.save, "GREAT_BALL", 5, game.data)
|
||||
-- DS_BALL forces the ball, so a MASTER_BALL run is a guaranteed catch
|
||||
-- and the experience payout can be read deterministically
|
||||
local FORCE = os.getenv("DS_BALL")
|
||||
if FORCE then
|
||||
Bag.add(game.save, FORCE, 5, game.data)
|
||||
CatchThrow.lastBall = FORCE
|
||||
end
|
||||
local expBefore = {}
|
||||
for i, m in ipairs(game.save.party) do
|
||||
expBefore[i] = { exp = m.exp, level = m.level }
|
||||
end
|
||||
LetsGo.setting:setValue(MODE, game)
|
||||
-- DS_RUNG=cards forces the 2D-3D A rung: the control run for the pic
|
||||
-- measurement path, against the STADIUM models the save may be on
|
||||
if os.getenv("DS_RUNG") == "cards" then
|
||||
lib.require("OverworldBattle").setting:setValue(true, game)
|
||||
U.log("3D-BTL forced to 2D-3D A")
|
||||
end
|
||||
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(90)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
-- the wipe and the "Wild X appeared!" chatter: tap until the menu (or
|
||||
-- capture mode, which under FULL opens the instant the menu would)
|
||||
U.wait(70)
|
||||
for i = 1, 80 do
|
||||
if battle.phase == "menu" or CatchThrow.session() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
if i % 20 == 0 then
|
||||
U.log(("still driving intro: phase=%s queue=%d anim=%s")
|
||||
:format(tostring(battle.phase), #(battle.queue or {}),
|
||||
tostring(battle.animPlaying)))
|
||||
end
|
||||
end
|
||||
|
||||
local function phase()
|
||||
local s = CatchThrow.session()
|
||||
return s and s.phase or ("battle:" .. tostring(battle.phase))
|
||||
end
|
||||
|
||||
if MODE == "catching" then
|
||||
-- the bag route: ITEM on the menu, then the ball
|
||||
U.wait(20)
|
||||
U.log("menu phase: " .. tostring(battle.phase))
|
||||
-- cursor to ITEM (menu is 2x2: fight pkmn / item run) -- down once
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(20)
|
||||
-- the bag opens on the first item; balls were added first
|
||||
U.tap(game, "a"); U.wait(10)
|
||||
else
|
||||
-- FULL: capture mode should have opened on its own
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
U.log("capture phase: " .. phase())
|
||||
do
|
||||
local s = CatchThrow.session()
|
||||
if s then
|
||||
local b = s.artBox
|
||||
U.log(("probe: span=%.1f enemyGB=%.0f,%.0f playerGB=%.0f,%.0f")
|
||||
:format(s.shot.enemySpan or -1, s.shot.enemy[1], s.shot.enemy[2],
|
||||
s.shot.player[1], s.shot.player[2]))
|
||||
U.log(("probe: eye=%.0f,%.0f,%.0f enemyW=%.0f,%.0f playerW=%.0f,%.0f")
|
||||
:format(s.shot.eye[1], s.shot.eye[2], s.shot.eye[3],
|
||||
s.enemyPos[1], s.enemyPos[3],
|
||||
s.playerPos[1], s.playerPos[3]))
|
||||
if b then
|
||||
U.log(("probe: artBox ax=%d ay=%d box=%d..%d,%d..%d body r=%.1f yOff=%.1f")
|
||||
:format(b.ax, b.ay, b.x0, b.x1, b.y0, b.y1,
|
||||
s.body.r, s.body.yOff))
|
||||
else
|
||||
U.log(("probe: artBox=nil body r=%.1f yOff=%.1f")
|
||||
:format(s.body.r, s.body.yOff))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.shot(game, DIR .. "/1_aim.png")
|
||||
|
||||
-- ------- the reach test
|
||||
--
|
||||
-- Drag the ball to the far corners of the WINDOW -- including well
|
||||
-- below where the battle's text box used to be -- and report where it
|
||||
-- actually ends up in the GB frame. A fence shows up here as a ball
|
||||
-- that stops moving while the pointer keeps going.
|
||||
do
|
||||
local W, H = love.graphics.getDimensions()
|
||||
local s = CatchThrow.session()
|
||||
if s and love.mousepressed then
|
||||
local hx, hy = s.handGB[1], s.handGB[2]
|
||||
love.mousepressed(W * 0.5, H * 0.62, 1, false, 1)
|
||||
U.wait(2)
|
||||
local CORNERS = {
|
||||
{ "bottom-centre", 0.50, 0.97 },
|
||||
{ "bottom-left", 0.06, 0.97 },
|
||||
{ "bottom-right", 0.94, 0.97 },
|
||||
{ "top-left", 0.06, 0.10 },
|
||||
}
|
||||
for _, c in ipairs(CORNERS) do
|
||||
for i = 1, 4 do
|
||||
if love.mousemoved then
|
||||
love.mousemoved(W * c[2], H * c[3], 0, 0, false)
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local q = CatchThrow.session()
|
||||
U.log(("reach %-14s -> ball GB %.0f,%.0f (frame is 160x144)")
|
||||
:format(c[1], q and q.handGB[1] or -1, q and q.handGB[2] or -1))
|
||||
if c[1] == "bottom-centre" then
|
||||
U.shot(game, DIR .. "/1b_drag_low.png")
|
||||
end
|
||||
end
|
||||
-- a slow release is a dead flick: nothing is thrown
|
||||
if love.mousereleased then
|
||||
love.mousereleased(W * 0.06, H * 0.10, 1, false, 1)
|
||||
end
|
||||
U.wait(30)
|
||||
U.log("after reach test: " .. phase()
|
||||
.. (" (ball home was %.0f,%.0f)"):format(hx, hy))
|
||||
end
|
||||
end
|
||||
|
||||
-- A synthetic flick aimed like a hand: from the session's own geometry,
|
||||
-- pick a release point short of the ring and a sweep speed for a
|
||||
-- comfortable sigma, so the aim model (release + reach along the flick)
|
||||
-- lands the ball on the ring centre. Window units; the session maps
|
||||
-- them back to GB through the live letterbox.
|
||||
local aim = CatchThrow._aimInfo()
|
||||
if aim then
|
||||
local uw, uh = love.graphics.getDimensions()
|
||||
local function toWin(gx, gy)
|
||||
return (aim.lx + gx * aim.scale) * uw / aim.pw,
|
||||
(aim.ly + gy * aim.scale) * uh / aim.ph
|
||||
end
|
||||
-- the throw is the swipe's own velocity now: grab the ball and sweep
|
||||
-- toward the ring at a comfortable ~190 GB px/s for 8 frames.
|
||||
-- DS_SWIPE overrides it, which is how the strength band is swept:
|
||||
-- weak should fall short, comfortable should land, hard should still
|
||||
-- reach rather than sail over.
|
||||
local SPEED = tonumber(os.getenv("DS_SWIPE") or "") or 190
|
||||
local FRAMES = 8
|
||||
SPEED_USED = SPEED
|
||||
local hx, hy = aim.hand[1], aim.hand[2]
|
||||
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
dx, dy = dx / d, dy / d
|
||||
U.log(("aim: hand %.0f,%.0f ring %.0f,%.0f outer %.0f")
|
||||
:format(hx, hy, aim.ring[1], aim.ring[2], aim.outer))
|
||||
local step = SPEED / 60
|
||||
if love.mousepressed then
|
||||
local px, py = toWin(hx, hy)
|
||||
love.mousepressed(px, py, 1, false, 1)
|
||||
end
|
||||
for i = 1, FRAMES do
|
||||
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
|
||||
if love.mousemoved then love.mousemoved(wx, wy, 0, 0, false) end
|
||||
U.wait(1)
|
||||
end
|
||||
if love.mousereleased then
|
||||
local wx, wy = toWin(hx + dx * step * FRAMES, hy + dy * step * FRAMES)
|
||||
love.mousereleased(wx, wy, 1, false, 1)
|
||||
end
|
||||
-- what actually left the hand, before gravity has touched it
|
||||
local s0 = CatchThrow.session()
|
||||
if s0 and s0.vel then
|
||||
local p = s0.ballInst.pos
|
||||
U.log(("LAUNCH fwd/up/lat = %.1f %.1f %.1f from height %.1f, %.1f px out")
|
||||
:format(math.sqrt(s0.vel[1] ^ 2 + s0.vel[3] ^ 2), s0.vel[2], 0,
|
||||
p[2] - s0.groundY,
|
||||
math.sqrt((s0.enemyPos[1] - p[1]) ^ 2
|
||||
+ (s0.enemyPos[3] - p[3]) ^ 2)))
|
||||
else
|
||||
U.log("LAUNCH -- nothing was thrown (flick rejected)")
|
||||
end
|
||||
else
|
||||
U.log("NO AIM INFO -- capture mode did not open")
|
||||
end
|
||||
|
||||
U.wait(4)
|
||||
-- did it CONNECT? poll the flight out and say plainly which way it
|
||||
-- went, so a strength sweep reads as a table instead of a guess
|
||||
do
|
||||
local verdict, peak = "no throw", 0
|
||||
for _ = 1, 200 do
|
||||
local s = CatchThrow.session()
|
||||
if not s then verdict = "session gone" break end
|
||||
if s.phase == "flight" then
|
||||
local h = s.ballInst.pos[2] - s.groundY
|
||||
if h > peak then peak = h end
|
||||
verdict = "MISS (fell short / wide)"
|
||||
elseif s.phase == "suck" or s.phase == "drop"
|
||||
or s.phase == "wobble" then
|
||||
verdict = "HIT" .. (s.tier and (" " .. s.tier) or " (outside ring)")
|
||||
break
|
||||
elseif s.phase ~= "aim" then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local body = CatchThrow.session() and CatchThrow.session().body
|
||||
U.log(("THROW swipe=%d -> %s (apex %.1f world px, body centre %.1f)")
|
||||
:format(SPEED_USED or -1, verdict, peak, body and body.yOff or -1))
|
||||
end
|
||||
U.log("after flick: " .. phase())
|
||||
U.shot(game, DIR .. "/2_flight.png")
|
||||
|
||||
-- the suck: ball open at the foe, beam on, mon shrinking
|
||||
for _ = 1, 60 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if s and s.phase == "suck" then break end
|
||||
if not CatchThrow.session() then break end
|
||||
end
|
||||
U.log("suck check: " .. phase()
|
||||
.. " shrink=" .. tostring(BattleScene.capture
|
||||
and BattleScene.capture.shrink))
|
||||
U.shot(game, DIR .. "/3_suck.png")
|
||||
|
||||
-- the drop and the first wobble
|
||||
for _ = 1, 120 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if s and s.phase == "wobble" then break end
|
||||
if not s then break end
|
||||
end
|
||||
U.wait(30)
|
||||
U.log("wobble check: " .. phase()
|
||||
.. " shrink=" .. tostring(BattleScene.capture
|
||||
and BattleScene.capture.shrink))
|
||||
U.shot(game, DIR .. "/4_wobble.png")
|
||||
|
||||
-- the outcome: stars or burst, then the engine's own text
|
||||
for _ = 1, 300 do
|
||||
U.wait(1)
|
||||
local s = CatchThrow.session()
|
||||
if not s or s.phase == "epilogue" or s.phase == "burst" then break end
|
||||
end
|
||||
U.wait(20)
|
||||
U.log("outcome: " .. phase())
|
||||
U.shot(game, DIR .. "/5_outcome.png")
|
||||
|
||||
-- under FULL a breakout must land straight back in throw mode -- no
|
||||
-- enemy turn between; a catch plays out its epilogue instead
|
||||
-- keep tapping through the caught chatter so storeCaughtMon (and the
|
||||
-- experience payout hanging off it) actually runs
|
||||
for _ = 1, 60 do U.tap(game, "a"); U.wait(6) end
|
||||
U.log(("after outcome: %s battle=%s balls=%d")
|
||||
:format(phase(), tostring(battle.phase),
|
||||
game.save.inventory.POKE_BALL or 0))
|
||||
for i, m in ipairs(game.save.party) do
|
||||
local was = expBefore[i]
|
||||
local nm = game.data.pokemon[m.species].name
|
||||
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
|
||||
:format(nm, was.level, m.level, was.exp, m.exp, m.exp - was.exp))
|
||||
end
|
||||
local combo = lib.require("LetsGo").combo()
|
||||
U.log("combo: " .. (combo and (combo.species .. " x" .. combo.count)
|
||||
or "none"))
|
||||
U.shot(game, DIR .. "/6_after.png")
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Driver: does a TRAINER knockout pay the whole party under LET'S GO FULL?
|
||||
--
|
||||
-- The catch payout is easy to see (one throw, one award). A knockout is
|
||||
-- not: it goes through the engine's own faint -> awardExp path, which is
|
||||
-- the one this mode replaces wholesale. So fight a real trainer with a
|
||||
-- deliberately uneven party and read the deltas -- every member should
|
||||
-- gain, and the low ones should gain multiples of the high one.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_trainer_exp.lua \
|
||||
-- SHOT_DIR=.scratchpad/letsgo "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- DS_LETSGO_MODE=off runs the same fight on the engine's own rules, which
|
||||
-- is the control: there, only the Pokemon that fought should gain.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local LetsGo = lib.require("LetsGo")
|
||||
LetsGo.setting:setValue(MODE == "off" and false or MODE, game)
|
||||
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
|
||||
|
||||
-- count the summary cards at their SOURCE rather than by catching them
|
||||
-- on the stack: the driver taps fast enough to dismiss one between two
|
||||
-- polls, and an absence there would look like a card that never came
|
||||
local ExpPanel = lib.require("ExpPanel")
|
||||
local innerNew, cards = ExpPanel.new, 0
|
||||
ExpPanel.new = function(g, rows)
|
||||
local panel = innerNew(g, rows)
|
||||
cards = cards + 1
|
||||
-- read at DRAW time in the real thing; here the loop that fills it
|
||||
-- has already run, so peeking now is honest
|
||||
local parts = {}
|
||||
for _, r in ipairs(rows or {}) do
|
||||
parts[#parts + 1] = ("%s+%d%s"):format(
|
||||
(g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0,
|
||||
(r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "")
|
||||
end
|
||||
U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " ")))
|
||||
CARD_HOLD = 20 -- frames to stop tapping, so it can be shot
|
||||
return panel
|
||||
end
|
||||
|
||||
-- one strong fighter and two bystanders: only the fighter gains on the
|
||||
-- engine's own rules, so the bystanders ARE the test
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
Pokemon.new(game.data, "RATTATA", 5),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- The WEAKEST party in the game: one Pokemon, lowest level. Picked by
|
||||
-- measuring rather than by name -- an alphabetical first pick lands on
|
||||
-- OPP_AGATHA, whose Elite Four ghosts a level 45 Charizard does not
|
||||
-- reliably clear, and a fight that never resolves reads exactly like a
|
||||
-- payout that never happened.
|
||||
local class, partyIx, best = nil, 1, nil
|
||||
local ids = {}
|
||||
for id in pairs(game.data.trainers) do
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_" then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids) -- stable across runs
|
||||
for _, id in ipairs(ids) do
|
||||
local rec = game.data.trainers[id]
|
||||
for pi, party in ipairs((type(rec) == "table" and rec.parties) or {}) do
|
||||
local n, top = 0, 0
|
||||
for _, mon in ipairs(party) do
|
||||
n = n + 1
|
||||
top = math.max(top, tonumber(mon.level) or 0)
|
||||
end
|
||||
if n > 0 then
|
||||
local score = n * 100 + top
|
||||
if not best or score < best then
|
||||
best, class, partyIx = score, id, pi
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("fighting %s party %d (weakest of %d classes)")
|
||||
:format(tostring(class), partyIx, #ids))
|
||||
|
||||
local before = {}
|
||||
for i, m in ipairs(game.save.party) do
|
||||
before[i] = { exp = m.exp, level = m.level }
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newTrainer(game, class, partyIx)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(70)
|
||||
|
||||
-- through the intro to the menu, then FIGHT + first move, over and
|
||||
-- over until the fight resolves. The enemy's HP is logged as it goes,
|
||||
-- so a fight that stalls is visibly a stall rather than a silent zero.
|
||||
local lastHP, shotPanel = nil, false
|
||||
for i = 1, 900 do
|
||||
-- the fight ending does NOT end the loop until the card has been
|
||||
-- photographed: the last knockout resolves the battle in the same
|
||||
-- breath that queues the card, so breaking on `result` alone leaves
|
||||
-- every run with the card built and never seen
|
||||
if battle.result and shotPanel then break end
|
||||
-- hold the taps while the card is up, so it can be shot rather than
|
||||
-- dismissed on the next frame
|
||||
if (CARD_HOLD or 0) > 0 then
|
||||
CARD_HOLD = CARD_HOLD - 1
|
||||
if not shotPanel then
|
||||
local top = game.stack and game.stack:top()
|
||||
if top and rawget(top, "rows") then
|
||||
shotPanel = true
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
|
||||
.. "/exp_panel.png")
|
||||
U.log("shot the card")
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
elseif battle.phase == "menu" then
|
||||
battle.menuIndex = 1 -- FIGHT
|
||||
U.tap(game, "a")
|
||||
elseif battle.phase == "moveSelect" then
|
||||
battle.moveIndex = 1
|
||||
U.tap(game, "a")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
local hp = battle.enemy and battle.enemy.mon and battle.enemy.mon.hp
|
||||
if hp ~= lastHP then
|
||||
lastHP = hp
|
||||
U.log((" foe HP -> %s (phase %s, step %d)")
|
||||
:format(tostring(hp), tostring(battle.phase), i))
|
||||
end
|
||||
-- photograph the summary card the moment it is on top, then let the
|
||||
-- taps carry on and dismiss it
|
||||
local top = game.stack and game.stack:top()
|
||||
if top and not shotPanel and top ~= battle and rawget(top, "rows") then
|
||||
shotPanel = true
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
|
||||
.. "/exp_panel.png")
|
||||
U.log("shot the card")
|
||||
end
|
||||
U.wait(5)
|
||||
end
|
||||
U.log("battle result: " .. tostring(battle.result)
|
||||
.. " phase=" .. tostring(battle.phase))
|
||||
|
||||
for i, m in ipairs(game.save.party) do
|
||||
local was = before[i]
|
||||
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
|
||||
:format(game.data.pokemon[m.species].name, was.level, m.level,
|
||||
was.exp, m.exp, m.exp - was.exp))
|
||||
end
|
||||
U.log("done")
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Scratch driver: the Game Freak office computer desks (buildings
|
||||
-- template mansion_computer_desk). CELADON_MANSION_2F cell (0,5) and
|
||||
-- CELADON_MANSION_3F cells (0,3)/(3,3)/(0,6). Shot at the voxel rung
|
||||
-- (5), the mid rung (4) and the flat rung (3) for orientation.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_desk_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
|
||||
-- AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR")
|
||||
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
|
||||
local TAG = os.getenv("AB_TAG") or "shot"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[deskshot] DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- prove the RUNNING mod's profile carries the new template
|
||||
local ok, s = pcall(V.data, "voxel_heights")
|
||||
local found = "NO"
|
||||
if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
|
||||
for _, t in ipairs(s.buildings.MANSION) do
|
||||
if t.id == "mansion_computer_desk" then
|
||||
found = ("YES fascia=%d-%d base=%d-%d parts=%d")
|
||||
:format(t.desk.fascia[1], t.desk.fascia[2],
|
||||
t.desk.base[1], t.desk.base[2], #t.parts)
|
||||
end
|
||||
end
|
||||
end
|
||||
print("[deskshot] running-mod mansion_computer_desk: " .. found)
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- 2F: the lone desk at (0,5):(1,6). Stand just south of it, facing
|
||||
-- north, so the camera looks along the desk's front face.
|
||||
{ map = "CELADON_MANSION_2F", x = 1, y = 7, face = "up", label = "desk2f" },
|
||||
-- and from the east, to see the drawer pedestal and the chair in
|
||||
-- profile against the checker floor
|
||||
{ map = "CELADON_MANSION_2F", x = 2, y = 6, face = "left", label = "desk2f_side" },
|
||||
-- 3F: the pair at (0,3) and (3,3), both in frame from between them
|
||||
{ map = "CELADON_MANSION_3F", x = 2, y = 5, face = "up", label = "desk3f_pair" },
|
||||
-- 3F: the south desk at (0,6), close in
|
||||
{ map = "CELADON_MANSION_3F", x = 1, y = 8, face = "up", label = "desk3f" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 4, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[deskshot] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[deskshot] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[deskshot] %d shots into %s (tag %s)"):format(shots, ROOT, TAG))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Scratch driver: the CELADON_MANSION_1F square table (buildings
|
||||
-- template mansion_square_table, cells (0,6):(1,7)). Shot at the voxel
|
||||
-- rung (5) and the flat rung (3) for orientation, front/back/side.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR")
|
||||
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[mansion] DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- prove the RUNNING mod's profile carries the new template
|
||||
local ok, s = pcall(V.data, "voxel_heights")
|
||||
local found = false
|
||||
if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
|
||||
for _, t in ipairs(s.buildings.MANSION) do
|
||||
if t.id == "mansion_square_table" then found = true end
|
||||
end
|
||||
end
|
||||
print("[mansion] running-mod mansion_square_table: " .. tostring(found))
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- the rung-5 camera looks north from ~4 cells south of the player, so
|
||||
-- the room's 16-voxel south wall hides the 6-voxel table at the low
|
||||
-- rung; rungs 4 and 3 pitch over it. Stand just south of the table.
|
||||
{ map = "CELADON_MANSION_1F", x = 1, y = 8, face = "up", label = "sqtable" },
|
||||
{ map = "CELADON_MANSION_1F", x = 2, y = 6, face = "left", label = "sqtable_beside" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 4, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[mansion] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[mansion] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[mansion] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit
|
||||
-- rungs, to see what the roof surface wears down a TAPERED flank.
|
||||
--
|
||||
-- A tapered column's first drawn row is its silhouette cap, and the cap is
|
||||
-- outline black. The depth map spends most of the roof's depth above that
|
||||
-- cap, so a surface clamped onto it paints the outline the length of the
|
||||
-- slope and the course cycle beats against it -- black teeth marching down
|
||||
-- the hip. The surface belongs on the column's first PAINTED row instead.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcap")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[cap] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the
|
||||
-- two tapered roof bands standing on one map -- 16 drawn rows and 32,
|
||||
-- the two groups every other sloped building borrows its band table
|
||||
-- from. Standing south of each puts the hip end across the frame.
|
||||
local SCENES = {
|
||||
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" },
|
||||
{ map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" },
|
||||
{ map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[cap] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[cap] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Scratch driver: the Safari Zone's decorations — the "cut"-style round
|
||||
-- trees ($54/$55/$56/$57, one cell) and the stump/bush rows ($02/$03/
|
||||
-- $12/$13) — front and back, at the voxel rung (5) and the flat rung (3)
|
||||
-- for orientation. Same spots BEFORE and AFTER the pin change.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/safari_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/safari AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/safari")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[safari] DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- what does the RUNNING mod think tile 84 on FOREST is?
|
||||
local TS = V.require("TileShape")
|
||||
local shapes = TS.forMap({ tileset = { id = "FOREST",
|
||||
imageWidth = 128,
|
||||
imageHeight = 48 } })
|
||||
local s = shapes[84]
|
||||
print("[safari] running-mod tile 84: "
|
||||
.. (s and (tostring(s.class) .. "/" .. tostring(s.art)
|
||||
.. " authored=" .. tostring(s.authored)) or "nil"))
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- tree row at (2..5, 3), seen from the south = its front
|
||||
{ map = "SAFARI_ZONE_EAST", x = 4, y = 5, face = "up", label = "trees_front" },
|
||||
-- the same row from the north = its back
|
||||
{ map = "SAFARI_ZONE_EAST", x = 4, y = 1, face = "down", label = "trees_back" },
|
||||
-- the long tree line at (2..9, 6)
|
||||
{ map = "SAFARI_ZONE_EAST", x = 6, y = 8, face = "up", label = "treeline" },
|
||||
-- the stump/bush row along the top edge (8..19, 0)
|
||||
{ map = "SAFARI_ZONE_EAST", x = 12, y = 2, face = "up", label = "stumps_front" },
|
||||
-- Safari Center's west tree column, edge-on
|
||||
{ map = "SAFARI_ZONE_CENTER", x = 2, y = 4, face = "left", label = "center_trees" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[safari] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[safari] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[safari] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Driver: one STADIUM screenshot per species, every one of the 151 standing
|
||||
-- on the ENEMY mark in its standby loop.
|
||||
--
|
||||
-- stadium_shots.lua photographs a handful of deliberately awkward cases; this
|
||||
-- is the exhaustive sweep behind it. What it exists to catch is the class of
|
||||
-- fault that is per-MODEL and invisible to the headless QA pass -- a texture
|
||||
-- that comes out magenta, a mon standing in the floor or floating over it, a
|
||||
-- silhouette that is subtly the wrong shape -- none of which is a number
|
||||
-- stadium_anim_qa.lua can measure.
|
||||
--
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \
|
||||
-- POKEPORT_SPEED=4 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \
|
||||
-- lovec.exe .
|
||||
--
|
||||
-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT.
|
||||
--
|
||||
-- ------- everything but the foe is nailed down
|
||||
--
|
||||
-- The whole value of 151 shots is that they are comparable, so the staging is
|
||||
-- identical in every one: same map, same cells, same hour (the day/night
|
||||
-- cycle would otherwise relight every shot), same rung, and the same small
|
||||
-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest
|
||||
-- model in the set and so hides the least of the arena behind it. Whatever
|
||||
-- differs between two of these shots is the foe.
|
||||
--
|
||||
-- ------- the model/sprite verdict is the other half
|
||||
--
|
||||
-- A species whose pack will not load, or whose animation data the packer
|
||||
-- declined, does not error: StadiumMon.setSpecies answers false and the mode
|
||||
-- quietly stands the Game Boy's flat pic on the tile instead. That is the
|
||||
-- correct behaviour and it is nearly invisible in a screenshot at a glance --
|
||||
-- which is exactly why it needs counting rather than looking. Stadium.showing
|
||||
-- is asked for every species and the ones that decline are listed at the end.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local Battles = lib.require("OverworldBattle")
|
||||
local Stadium = lib.require("Stadium")
|
||||
local Pack = lib.require("StadiumPack")
|
||||
local Install = lib.require("StadiumInstall")
|
||||
|
||||
-- every real species the merged data carries, in dex order
|
||||
local species = {}
|
||||
for id, def in pairs(game.data.pokemon) do
|
||||
if type(id) == "string" and type(def) == "table"
|
||||
and def.dex and def.dex >= 1 and def.dex <= 151 then
|
||||
species[#species + 1] = { id = id, dex = def.dex }
|
||||
end
|
||||
end
|
||||
table.sort(species, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the
|
||||
-- mod's update hook on the first frame the overworld is the top state, so
|
||||
-- waiting for it before teleporting waits forever.
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
|
||||
local waited = 0
|
||||
while (Install.pending() or Install.status.state == "building")
|
||||
and waited < 5400 do
|
||||
U.wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if waited > 0 then
|
||||
U.log(("waited %.1fs for the model build -- %s")
|
||||
:format(waited / 60, tostring(Install.status.state)))
|
||||
end
|
||||
|
||||
Battles.setting:setValue("stadium", game)
|
||||
Battles.backSetting:setValue(false, game)
|
||||
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
|
||||
|
||||
local have = 0
|
||||
for dex = 1, 151 do
|
||||
if Pack.available(dex) then have = have + 1 end
|
||||
end
|
||||
U.log(("%d species, %d/151 packs on disk, rung = %s")
|
||||
:format(#species, have, tostring(Battles.setting:get())))
|
||||
|
||||
-- let the neighbourhood's meshes land, so the first fight opens on the
|
||||
-- real arena rather than the flat fallback
|
||||
U.wait(90)
|
||||
|
||||
local asPic, stuck, missed = {}, {}, {}
|
||||
|
||||
for _, s in ipairs(species) do
|
||||
-- the player's side is a constant (see the header), so the only thing
|
||||
-- that changes between two shots is the Pokemon being verified
|
||||
game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) }
|
||||
|
||||
local battle = BattleState.newWild(game, s.id, 30)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
-- the wipe and the intro chatter, then far enough past it that the
|
||||
-- player's own Pokemon has been sent out -- before that the player's
|
||||
-- side is legitimately the trainer's back sprite and the foe is still
|
||||
-- sliding in
|
||||
U.wait(70)
|
||||
for _ = 1, 40 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
if battle.phase ~= "menu" then
|
||||
stuck[#stuck + 1] = s.id
|
||||
U.log(("STUCK before menu: %s (phase %s)")
|
||||
:format(s.id, tostring(battle.phase)))
|
||||
end
|
||||
-- and let the send-out beat finish so the mon is standing still in its
|
||||
-- standby loop rather than mid-arrival
|
||||
U.wait(45)
|
||||
|
||||
-- model or flat pic? asked here, with the fight actually on screen
|
||||
if not Stadium.showing("enemy") then
|
||||
asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id)
|
||||
end
|
||||
|
||||
local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())
|
||||
if not U.shot(game, path) then
|
||||
missed[#missed + 1] = s.id
|
||||
end
|
||||
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
U.log(("---- %d shots -> %s"):format(#species - #missed, DIR))
|
||||
if #asPic > 0 then
|
||||
U.log(("%d species declined to a model and were shot as FLAT PICS: %s")
|
||||
:format(#asPic, table.concat(asPic, ", ")))
|
||||
else
|
||||
U.log("all species stood as MODELS -- none fell back to a flat pic")
|
||||
end
|
||||
if #stuck > 0 then
|
||||
U.log(("%d never reached the battle menu: %s")
|
||||
:format(#stuck, table.concat(stuck, ", ")))
|
||||
end
|
||||
if #missed > 0 then
|
||||
U.log(("%d screenshots did not reach disk: %s")
|
||||
:format(#missed, table.concat(missed, ", ")))
|
||||
end
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -44,10 +44,14 @@ return function(game)
|
||||
{ "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest
|
||||
{ "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it
|
||||
{ "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set
|
||||
-- the two species whose animation data is corrupt at source: BOTH of
|
||||
-- these must come out as flat battle pics standing on their tiles, not
|
||||
-- as models (see StadiumMon.setSpecies). Tangela is the third.
|
||||
-- the five hermite-keyframe species (flags & 8), which the extractor
|
||||
-- used to misread as packed streams: Exeggutor, Tangela and Magmar came
|
||||
-- out so garbled the packer declined them to flat pics, and Pidgeot and
|
||||
-- Dodrio animated garbled. All five must now stand as MODELS in a sane
|
||||
-- standby loop (see StadiumFragment's animation notes).
|
||||
{ "EXEGGUTOR", "MAGMAR" },
|
||||
{ "PIDGEOT", "TANGELA" },
|
||||
{ "DODRIO", "PIDGEOTTO" }, -- with its packed-stream evolution as control
|
||||
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Scratch driver: the round tables of the Celadon diner and the Mart
|
||||
-- roof terrace (the diner_round_table template's four placements).
|
||||
-- Shot at the voxel rung (5) and the flat rung (3), front/back/side of
|
||||
-- the diner table at cells (0,5):(1,6) plus the terrace pair.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/table_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
|
||||
-- AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR")
|
||||
or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
|
||||
local TAG = os.getenv("AB_TAG") or "shot"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[table] DRAMATIC_SHAPE is not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local Buildings = V.require("Buildings")
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- the diner table at (0,5):(1,6), seen from the south = its front
|
||||
{ map = "CELADON_DINER", x = 1, y = 7, face = "up", label = "diner_front" },
|
||||
-- the same table from the north = its back (standing between the two)
|
||||
{ map = "CELADON_DINER", x = 0, y = 4, face = "down", label = "diner_back" },
|
||||
-- edge-on from the east
|
||||
{ map = "CELADON_DINER", x = 3, y = 5, face = "left", label = "diner_side" },
|
||||
-- the roof terrace's table at (4,2):(5,3), from the south
|
||||
{ map = "CELADON_MART_ROOF", x = 4, y = 4, face = "up", label = "roof_front" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
-- prove which models the RUNNING mod built for this map
|
||||
local st = Buildings.stats()
|
||||
local keys = {}
|
||||
for k, v in pairs(st) do
|
||||
keys[#keys + 1] = ("%s(v=%d)"):format(k, v.voxels)
|
||||
end
|
||||
table.sort(keys)
|
||||
print(("[table] %s r%d models: %s"):format(s.label, rung,
|
||||
#keys > 0 and table.concat(keys, " ") or "none"))
|
||||
local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[table] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[table] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[table] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,816 @@
|
||||
-- Driver: the tile picker -- point at a tile, get the voxelize prompt.
|
||||
--
|
||||
-- The voxelize-tiles skill is driven per TILE, and its first argument is
|
||||
-- always the same thing: which map, and which cell on it. Finding that pair
|
||||
-- by hand means walking the game to the object, counting cells off a corner
|
||||
-- and hoping the count is right. This is the same window the arena editor is
|
||||
-- -- the map drawn from its OWN art, the tiles the cartridge holds, through
|
||||
-- the game's own palette -- with a cursor on it instead of an arena, and one
|
||||
-- button: COPY PROMPT.
|
||||
--
|
||||
-- What lands on the clipboard is exactly:
|
||||
--
|
||||
-- run voxelize-tile on the tile found in PALLET_TOWN- (5,4). Return
|
||||
-- screenshots of the voxelization in .claude/voxelizations/
|
||||
--
|
||||
-- on one line, ready to paste into Claude. Every copy is also appended to
|
||||
-- SHOT_DIR/prompts.txt and printed to the console, because a session of this
|
||||
-- is a LIST of jobs -- you walk a map picking out every object worth shape
|
||||
-- and hand the list over at the end, rather than pasting one and waiting.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tile_pick.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so
|
||||
-- the console is attached and the transcript alone is the list.
|
||||
--
|
||||
-- ------- the keys
|
||||
--
|
||||
-- arrows move the cursor (hold shift for 5 cells)
|
||||
-- click put the cursor on that tile
|
||||
-- [ ] previous / next map (shift: 10 at a time)
|
||||
-- c / enter COPY PROMPT for the tile under the cursor
|
||||
-- z zoom the inset in / out (shift: out)
|
||||
-- g what the plan shows: the map's own tiles, the walk grid over
|
||||
-- them, or the walk grid alone
|
||||
-- m HIDE the plan, so the window is the game -- the panel keeps
|
||||
-- the coordinates, the inset and the button, so a tile is
|
||||
-- still copied from here while looking at it standing up in 3D
|
||||
-- t stand the player on this tile, so the 3D view behind the
|
||||
-- panel is looking at the thing you are about to voxelize
|
||||
-- y screenshot (shift: with this panel in it)
|
||||
-- l re-print every prompt copied so far
|
||||
-- F1 the key legend on screen
|
||||
-- escape quit
|
||||
--
|
||||
-- ------- environment
|
||||
--
|
||||
-- TILE_MAPS=ID,ID work this list instead of every map in the game
|
||||
-- TILE_START=ID open on this map
|
||||
-- TILE_OUT=path where prompts.txt is written
|
||||
-- TILE_RUNG=N the voxel angle rung to sit on (default 3)
|
||||
-- SHOT_DIR=path screenshots, and the default prompt-list directory
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end
|
||||
|
||||
local DIR = os.getenv("SHOT_DIR")
|
||||
if not DIR or DIR == "" then DIR = ".scratchpad/tile_pick" end
|
||||
local OUT = os.getenv("TILE_OUT")
|
||||
if not OUT or OUT == "" then OUT = DIR .. "/prompts.txt" end
|
||||
local RUNG = tonumber(os.getenv("TILE_RUNG") or "") or 3
|
||||
|
||||
-- U.shot's own mkdir is Unix-flavoured and does nothing on Windows, which
|
||||
-- is where this tool is driven from -- so the shell is asked in its own
|
||||
-- language, or the screenshots and the prompt list never reach disk.
|
||||
local WINDOWS = love.system and love.system.getOS() == "Windows"
|
||||
local function mkdirp(dir)
|
||||
if not dir or dir == "" then return end
|
||||
if WINDOWS then
|
||||
local d = dir:gsub("/", "\\")
|
||||
os.execute('if not exist "' .. d .. '" mkdir "' .. d .. '"')
|
||||
else
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
end
|
||||
end
|
||||
mkdirp(DIR)
|
||||
mkdirp(OUT:match("^(.*)[/\\][^/\\]+$"))
|
||||
|
||||
-- ------- the mod
|
||||
--
|
||||
-- Only for the camera rung: the picking itself is engine-only, but the
|
||||
-- view behind the panel is what `t` is for, and a flat 2D world behind a
|
||||
-- 3D-tile picker would be looking at the wrong thing.
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local wasRung
|
||||
if lib then
|
||||
local DayNight = lib.require("DayNight")
|
||||
DayNight.setting:setValue("day") -- one light to look at them all in
|
||||
wasRung = Pipelines.level("voxel")
|
||||
Pipelines.setLevel("voxel", RUNG)
|
||||
else
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- the picker still works, but the "
|
||||
.. "view behind it will be the flat 2D map")
|
||||
end
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- ------- which maps
|
||||
--
|
||||
-- EVERY map, unlike the arena tools: a tile worth voxelizing is as likely
|
||||
-- to be a shop counter or a bedroom desk as anything on a route, and those
|
||||
-- are exactly the maps a "where can a fight happen" filter throws away.
|
||||
local ids = {}
|
||||
local explicit = os.getenv("TILE_MAPS")
|
||||
if explicit and explicit ~= "" then
|
||||
for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
|
||||
else
|
||||
for id, def in pairs(game.data.maps) do
|
||||
if type(id) == "string" and type(def) == "table" and def.width then
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(ids)
|
||||
end
|
||||
if #ids == 0 then
|
||||
U.log("no maps to work on")
|
||||
return
|
||||
end
|
||||
|
||||
local S = {
|
||||
i = 1,
|
||||
map = nil,
|
||||
cx = 0, cy = 0, -- the cursor, in cells
|
||||
plan = nil,
|
||||
tiles = nil, -- the map baked from its own tileset
|
||||
planMode = 1,
|
||||
showPlan = true, -- is the plan drawn at all
|
||||
zoom = 5, -- how many cells across the inset shows
|
||||
msg = "",
|
||||
legend = true,
|
||||
copied = {}, -- every prompt this session, in order
|
||||
shooting = false,
|
||||
quit = false,
|
||||
planRect = nil, -- where the plan landed, for the mouse
|
||||
buttonRect = nil, -- and the button
|
||||
}
|
||||
|
||||
local queue = {}
|
||||
local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end
|
||||
local function say(fmt, ...)
|
||||
S.msg = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
local function mapId() return ids[S.i] end
|
||||
|
||||
-- ------- the prompt
|
||||
--
|
||||
-- One line, and the ONLY thing this tool exists to produce. Kept in one
|
||||
-- function so the text that reaches the clipboard, the console and the
|
||||
-- file is provably the same text.
|
||||
local function promptFor(id, cx, cy)
|
||||
return ("run voxelize-tile on the tile found in %s- (%d,%d). "
|
||||
.. "Return screenshots of the voxelization in "
|
||||
.. ".claude/voxelizations/"):format(id, cx, cy)
|
||||
end
|
||||
|
||||
local function copyPrompt()
|
||||
local text = promptFor(mapId(), S.cx, S.cy)
|
||||
local ok = false
|
||||
if love.system and love.system.setClipboardText then
|
||||
ok = pcall(love.system.setClipboardText, text)
|
||||
end
|
||||
S.copied[#S.copied + 1] = text
|
||||
-- The clipboard holds ONE of these and the session produces many, so the
|
||||
-- file is not a convenience -- it is where the list actually lives. Open
|
||||
-- per copy and closed again: a tool driving a game can be killed at any
|
||||
-- moment, and a buffered list is a lost afternoon.
|
||||
local f = io.open(OUT, "a")
|
||||
if f then
|
||||
f:write(text, "\n")
|
||||
f:close()
|
||||
end
|
||||
U.log("PROMPT " .. text)
|
||||
say("%s (%d,%d) copied%s -- %d so far", mapId(), S.cx, S.cy,
|
||||
ok and "" or " to the list only (no clipboard here)", #S.copied)
|
||||
end
|
||||
|
||||
-- ------- what the map looks like from above
|
||||
--
|
||||
-- Lifted from the arena editor, and for its reason: the plan is the
|
||||
-- surface a tile is chosen on, and a grid of coloured squares is a map of
|
||||
-- the walk grid rather than of the place. You are picking out a bookshelf.
|
||||
-- Baked once per map into a canvas, during UPDATE rather than inside a
|
||||
-- draw, because it binds a canvas of its own.
|
||||
local PLAN_MAX = 2048
|
||||
|
||||
local function paletteFor(m)
|
||||
local ow = game.overworld
|
||||
if ow and ow.paletteNameFor then
|
||||
local okName, name = pcall(ow.paletteNameFor, ow, m)
|
||||
if okName and name then
|
||||
local okPal, colours = pcall(PaletteFX.pal, game.data, name)
|
||||
if okPal and colours then return colours end
|
||||
end
|
||||
end
|
||||
local okOg, og = pcall(PaletteFX.ogBg)
|
||||
return okOg and og or nil
|
||||
end
|
||||
|
||||
local function bakeTiles()
|
||||
if S.tiles then pcall(S.tiles.release, S.tiles) end
|
||||
S.tiles = nil
|
||||
local m = S.map
|
||||
if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then
|
||||
return
|
||||
end
|
||||
local w, h = m.widthCells * 16, m.heightCells * 16
|
||||
local scale = math.min(1, PLAN_MAX / math.max(w, h))
|
||||
local okNew, canvas = pcall(love.graphics.newCanvas,
|
||||
math.max(1, math.floor(w * scale)),
|
||||
math.max(1, math.floor(h * scale)))
|
||||
if not (okNew and canvas) then return end
|
||||
-- Nearest, unlike the arena editor's plan: that one is a whole route
|
||||
-- minified to a panel, where nearest keeps a moire. This one gets
|
||||
-- magnified in the inset, where a tile has to be READABLE -- linear
|
||||
-- there is a smear of a bookshelf, and the drawing is the whole point.
|
||||
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||
|
||||
local g = love.graphics
|
||||
local prev = g.getCanvas()
|
||||
local wasTrue = m.renderer.trueColor
|
||||
m.renderer.trueColor = false
|
||||
local ok, err = pcall(function()
|
||||
g.push("all")
|
||||
g.origin()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.scale(scale, scale)
|
||||
local shader = PaletteFX.shader()
|
||||
local colours = paletteFor(m)
|
||||
if shader and colours then
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, colours)
|
||||
end
|
||||
m.renderer:drawMapOnly(0, 0, w, h)
|
||||
g.pop()
|
||||
end)
|
||||
m.renderer.trueColor = wasTrue
|
||||
if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end
|
||||
if not ok then
|
||||
pcall(canvas.release, canvas)
|
||||
U.log("could not bake the map's tiles: " .. tostring(err))
|
||||
return
|
||||
end
|
||||
S.tiles = canvas
|
||||
end
|
||||
|
||||
local PLAN_MODES = { "both", "tiles", "grid" }
|
||||
|
||||
local CELL_COLOUR = {
|
||||
open = { 0.78, 0.74, 0.63 },
|
||||
grass = { 0.33, 0.60, 0.30 },
|
||||
water = { 0.24, 0.44, 0.78 },
|
||||
warp = { 0.85, 0.68, 0.20 },
|
||||
solid = { 0.17, 0.17, 0.20 },
|
||||
}
|
||||
|
||||
local function buildPlan()
|
||||
local m = S.map
|
||||
if not m then S.plan = nil return end
|
||||
local w, h = m.widthCells, m.heightCells
|
||||
local plan = { w = w, h = h }
|
||||
for cy = 0, h - 1 do
|
||||
for cx = 0, w - 1 do
|
||||
local kind
|
||||
if not m:isWalkableCell(cx, cy) then
|
||||
kind = m:isWaterCell(cx, cy) and "water" or "solid"
|
||||
elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then
|
||||
kind = "warp"
|
||||
elseif m.isGrassCell and m:isGrassCell(cx, cy) then
|
||||
kind = "grass"
|
||||
else
|
||||
kind = "open"
|
||||
end
|
||||
plan[cy * w + cx] = kind
|
||||
end
|
||||
end
|
||||
S.plan = plan
|
||||
end
|
||||
|
||||
-- ------- moving between maps
|
||||
|
||||
local function enterMap(index)
|
||||
S.i = ((index - 1) % #ids) + 1
|
||||
local id = mapId()
|
||||
local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end)
|
||||
if not ok then
|
||||
S.map, S.plan = nil, nil
|
||||
say("could not enter %s (%s)", id, tostring(err))
|
||||
U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
|
||||
return
|
||||
end
|
||||
S.map = game.overworld.map
|
||||
buildPlan()
|
||||
bakeTiles()
|
||||
-- the middle of the map, which is where the furniture tends to be
|
||||
S.cx = math.floor(S.map.widthCells / 2)
|
||||
S.cy = math.floor(S.map.heightCells / 2)
|
||||
say("%s -- %dx%d cells", id, S.map.widthCells, S.map.heightCells)
|
||||
U.log(("MAP %s (%d/%d) %dx%d cells"):format(id, S.i, #ids,
|
||||
S.map.widthCells, S.map.heightCells))
|
||||
end
|
||||
|
||||
-- ------- the frame
|
||||
--
|
||||
-- The driver harness turns the frame cap off (a scripted run is not
|
||||
-- paced), so each step is held to a 60th of a second by hand.
|
||||
local lastT = love.timer.getTime()
|
||||
local function step(n)
|
||||
for _ = 1, (n or 1) do
|
||||
local rest = (1 / 60) - (love.timer.getTime() - lastT)
|
||||
if rest > 0 then love.timer.sleep(rest) end
|
||||
lastT = love.timer.getTime()
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
|
||||
local function shoot(withHud)
|
||||
-- the plan being away is part of what a panel shot IS, or the two write
|
||||
-- one file twice and whichever came last is the only one kept
|
||||
local tag = ""
|
||||
if withHud then tag = S.showPlan and "_panel" or "_panel_noplan" end
|
||||
local name = ("%s_x%d_y%d%s"):format(mapId():lower(), S.cx, S.cy, tag)
|
||||
local path = ("%s/%s.png"):format(DIR, name)
|
||||
S.shooting = not withHud
|
||||
game.capturePath = path
|
||||
for _ = 1, 120 do
|
||||
if not game.capturePath then break end
|
||||
step(1)
|
||||
end
|
||||
step(2)
|
||||
S.shooting = false
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
say("shot %s.png", name)
|
||||
U.log("SHOT " .. path)
|
||||
else
|
||||
say("screenshot did not reach disk: %s", path)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stand the player on the tile, so the world behind the panel is looking
|
||||
-- at the thing about to be voxelized. The tile itself is usually SOLID --
|
||||
-- a counter, a shelf, a house -- so the walk of shame outwards: the
|
||||
-- nearest walkable cell to it, searched in rings, which is where a player
|
||||
-- would have to stand to see it anyway.
|
||||
local function standHere()
|
||||
local m = S.map
|
||||
if not m then return end
|
||||
for r = 0, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local x, y = S.cx + dx, S.cy + dy
|
||||
if m:inBounds(x, y) and m:isWalkableCell(x, y) then
|
||||
game.overworld.player.cellX = x
|
||||
game.overworld.player.cellY = y
|
||||
say("standing at %d,%d", x, y)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
say("nowhere walkable within 6 cells of %d,%d", S.cx, S.cy)
|
||||
end
|
||||
|
||||
-- ------- the keys
|
||||
|
||||
local function shifted()
|
||||
return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")
|
||||
end
|
||||
|
||||
local function moveCursor(dx, dy)
|
||||
local m = S.map
|
||||
if not m then return end
|
||||
local n = shifted() and 5 or 1
|
||||
S.cx = math.max(0, math.min(m.widthCells - 1, S.cx + dx * n))
|
||||
S.cy = math.max(0, math.min(m.heightCells - 1, S.cy + dy * n))
|
||||
end
|
||||
|
||||
local function handle(key)
|
||||
if key == "left" then moveCursor(-1, 0) return true end
|
||||
if key == "right" then moveCursor(1, 0) return true end
|
||||
if key == "up" then moveCursor(0, -1) return true end
|
||||
if key == "down" then moveCursor(0, 1) return true end
|
||||
if key == "[" then post("map", shifted() and -10 or -1) return true end
|
||||
if key == "]" then post("map", shifted() and 10 or 1) return true end
|
||||
if key == "c" or key == "return" or key == "kpenter" then
|
||||
copyPrompt()
|
||||
return true
|
||||
end
|
||||
if key == "z" then
|
||||
S.zoom = math.max(3, math.min(21, S.zoom + (shifted() and 2 or -2)))
|
||||
return true
|
||||
end
|
||||
if key == "g" then
|
||||
S.planMode = (S.planMode % #PLAN_MODES) + 1
|
||||
say("plan: %s", PLAN_MODES[S.planMode])
|
||||
return true
|
||||
end
|
||||
-- The plan covers most of the window, and the thing UNDER it is the game
|
||||
-- -- the tile standing up in 3D, which is what the voxelizing is for.
|
||||
-- With the plan away, the panel still carries the coordinates, the inset
|
||||
-- and the button, so a tile can still be copied while looking at it.
|
||||
if key == "m" then
|
||||
S.showPlan = not S.showPlan
|
||||
say("plan %s", S.showPlan and "shown" or "hidden -- the game is under it")
|
||||
return true
|
||||
end
|
||||
if key == "t" then standHere() return true end
|
||||
if key == "y" then post("shot", shifted() and "hud" or nil) return true end
|
||||
if key == "l" then
|
||||
if #S.copied == 0 then
|
||||
U.log("nothing copied yet")
|
||||
else
|
||||
for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end
|
||||
end
|
||||
say("%d prompt%s printed to the console", #S.copied,
|
||||
#S.copied == 1 and "" or "s")
|
||||
return true
|
||||
end
|
||||
if key == "f1" then S.legend = not S.legend return true end
|
||||
if key == "escape" then S.quit = true return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local innerKey = love.keypressed
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
local ok, claimed = pcall(handle, key)
|
||||
if ok and claimed then return end
|
||||
if not ok then U.log("key error: " .. tostring(claimed)) end
|
||||
if innerKey then return innerKey(key, scancode, isrepeat) end
|
||||
end
|
||||
|
||||
-- The mouse is the reason this tool is faster than counting cells: click
|
||||
-- the bookshelf, read the coordinates back off the panel, copy. Both hit
|
||||
-- tests read rectangles the draw left behind, so what is clickable is
|
||||
-- exactly what was drawn -- no second copy of the layout to drift.
|
||||
local innerMouse = love.mousepressed
|
||||
function love.mousepressed(x, y, button, istouch, presses)
|
||||
if button == 1 then
|
||||
local b = S.buttonRect
|
||||
if b and x >= b[1] and y >= b[2] and x <= b[1] + b[3]
|
||||
and y <= b[2] + b[4] then
|
||||
copyPrompt()
|
||||
return
|
||||
end
|
||||
local r = S.planRect
|
||||
if r and S.map and x >= r.x and y >= r.y and x <= r.x + r.w
|
||||
and y <= r.y + r.h then
|
||||
S.cx = math.max(0, math.min(S.map.widthCells - 1,
|
||||
math.floor((x - r.x) / r.cell)))
|
||||
S.cy = math.max(0, math.min(S.map.heightCells - 1,
|
||||
math.floor((y - r.y) / r.cell)))
|
||||
return
|
||||
end
|
||||
end
|
||||
if innerMouse then
|
||||
return innerMouse(x, y, button, istouch, presses)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local font = love.graphics.newFont(13)
|
||||
local smallFont = love.graphics.newFont(11)
|
||||
local bigFont = love.graphics.newFont(16)
|
||||
|
||||
local function drawPlan(x, y, w, h)
|
||||
local plan = S.plan
|
||||
S.planRect = nil
|
||||
if not plan then return end
|
||||
local g = love.graphics
|
||||
local cell = math.min(w / plan.w, h / plan.h)
|
||||
if cell < 1 then cell = 1 end
|
||||
local pw, ph = plan.w * cell, plan.h * cell
|
||||
local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2
|
||||
S.planRect = { x = ox, y = oy, w = pw, h = ph, cell = cell }
|
||||
|
||||
g.setColor(0, 0, 0, 0.55)
|
||||
g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8)
|
||||
|
||||
local mode = PLAN_MODES[S.planMode or 1]
|
||||
if S.tiles and mode ~= "grid" then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(),
|
||||
ph / S.tiles:getHeight())
|
||||
end
|
||||
if mode ~= "tiles" then
|
||||
local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22
|
||||
for cy = 0, plan.h - 1 do
|
||||
for cx = 0, plan.w - 1 do
|
||||
local kind = plan[cy * plan.w + cx]
|
||||
local c = CELL_COLOUR[kind]
|
||||
if c and not (alpha < 1 and kind == "open") then
|
||||
g.setColor(c[1], c[2], c[3], alpha)
|
||||
g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if ow and ow.player then
|
||||
g.setColor(1, 1, 1, 0.8)
|
||||
g.circle("fill", ox + (ow.player.cellX + 0.5) * cell,
|
||||
oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4))
|
||||
end
|
||||
|
||||
-- The cursor, with crosshair arms out to the edges of the plan. On a
|
||||
-- route the cell is three pixels across and a box round it is invisible;
|
||||
-- the arms are what actually says WHICH one is selected.
|
||||
local cx, cy = ox + S.cx * cell, oy + S.cy * cell
|
||||
g.setColor(1, 0.85, 0.2, 0.35)
|
||||
g.setLineWidth(1)
|
||||
g.line(ox, cy + cell / 2, ox + pw, cy + cell / 2)
|
||||
g.line(cx + cell / 2, oy, cx + cell / 2, oy + ph)
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.setLineWidth(2)
|
||||
g.rectangle("line", cx - 1, cy - 1, cell + 2, cell + 2)
|
||||
g.setLineWidth(1)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- The tile itself, magnified. The whole judgement this tool supports is
|
||||
-- "is THAT the drawing I mean to voxelize", and at plan scale a cell is a
|
||||
-- few pixels. Drawn as a scissored blow-up of the same baked canvas rather
|
||||
-- than a second render of the map: one bake, one truth.
|
||||
local function drawInset(x, y, size)
|
||||
local g = love.graphics
|
||||
g.setColor(0, 0, 0, 0.7)
|
||||
g.rectangle("fill", x - 3, y - 3, size + 6, size + 6)
|
||||
if not (S.tiles and S.map) then return end
|
||||
local span = S.zoom -- cells across the inset
|
||||
local pxPerCell = S.tiles:getWidth() / S.map.widthCells
|
||||
local mag = size / (span * pxPerCell)
|
||||
-- the top-left canvas pixel the inset starts at, so the cursor's cell
|
||||
-- sits in the middle of it
|
||||
local sx = (S.cx + 0.5 - span / 2) * pxPerCell
|
||||
local sy = (S.cy + 0.5 - span / 2) * pxPerCell
|
||||
g.setScissor(x, y, size, size)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(S.tiles, x - sx * mag, y - sy * mag, 0, mag, mag)
|
||||
g.setScissor()
|
||||
local cell = size / span
|
||||
local mx = x + size / 2 - cell / 2
|
||||
local my = y + size / 2 - cell / 2
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.setLineWidth(2)
|
||||
g.rectangle("line", mx, my, cell, cell)
|
||||
g.setLineWidth(1)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local LEGEND = {
|
||||
"arrows move the cursor (shift x5)",
|
||||
"click put the cursor on that tile",
|
||||
"[ ] previous / next map (shift x10)",
|
||||
"c COPY PROMPT (also enter, also the button)",
|
||||
"z zoom the inset in (shift: out)",
|
||||
"g plan: tiles / walk grid / both",
|
||||
"m hide the plan (the game is under it)",
|
||||
"t stand the player on this tile",
|
||||
"y screenshot (shift: with this panel)",
|
||||
"l list every prompt copied so far",
|
||||
"F1 this list escape quit",
|
||||
}
|
||||
|
||||
local function drawOverlay()
|
||||
local g = love.graphics
|
||||
local W, H = g.getDimensions()
|
||||
local panel = math.min(320, math.floor(W * 0.34))
|
||||
|
||||
g.setColor(0, 0, 0, 0.72)
|
||||
g.rectangle("fill", 0, 0, panel, H)
|
||||
|
||||
local y = 10
|
||||
g.setFont(bigFont)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.print(mapId(), 10, y)
|
||||
y = y + 22
|
||||
g.setFont(font)
|
||||
g.setColor(0.72, 0.78, 0.9, 1)
|
||||
g.print(("map %d / %d"):format(S.i, #ids), 10, y)
|
||||
y = y + 20
|
||||
|
||||
-- the coordinates, big, because they are what the prompt is made of and
|
||||
-- what gets read back against the game
|
||||
g.setFont(bigFont)
|
||||
g.setColor(1, 0.85, 0.2, 1)
|
||||
g.print(("tile (%d,%d)"):format(S.cx, S.cy), 10, y)
|
||||
y = y + 24
|
||||
g.setFont(smallFont)
|
||||
g.setColor(0.8, 0.8, 0.85, 1)
|
||||
local m = S.map
|
||||
if m then
|
||||
local okTile, tile = pcall(m.cellTile, m, S.cx, S.cy)
|
||||
local kind = S.plan and S.plan[S.cy * S.plan.w + S.cx] or "?"
|
||||
-- tileset and tile id are not in the prompt, and are here anyway: they
|
||||
-- are what the skill's data files are keyed by, so this is the line
|
||||
-- that tells you two different-looking cells are the same drawing
|
||||
g.print(("tileset %s tile %s %s")
|
||||
:format(tostring(m.def and m.def.tileset),
|
||||
okTile and tostring(tile) or "?", kind), 10, y)
|
||||
y = y + 16
|
||||
g.print(("block (%d,%d)"):format(math.floor(S.cx / 2),
|
||||
math.floor(S.cy / 2)), 10, y)
|
||||
y = y + 18
|
||||
end
|
||||
|
||||
-- the inset
|
||||
local inset = math.min(panel - 20, 180)
|
||||
drawInset(10, y, inset)
|
||||
y = y + inset + 14
|
||||
|
||||
-- THE BUTTON. A real rectangle with a real hit test, not a hint that a
|
||||
-- key exists: the tool is used with one hand on the mouse walking a map,
|
||||
-- and reaching for the keyboard per tile is the whole friction.
|
||||
local bw, bh = panel - 20, 34
|
||||
S.buttonRect = { 10, y, bw, bh }
|
||||
local mx, my = love.mouse.getPosition()
|
||||
local hot = mx >= 10 and my >= y and mx <= 10 + bw and my <= y + bh
|
||||
g.setColor(hot and 0.30 or 0.18, hot and 0.62 or 0.42, hot and 0.34 or 0.22,
|
||||
1)
|
||||
g.rectangle("fill", 10, y, bw, bh, 5, 5)
|
||||
g.setColor(0.45, 0.9, 0.55, 1)
|
||||
g.rectangle("line", 10, y, bw, bh, 5, 5)
|
||||
g.setFont(font)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
local label = "COPY PROMPT"
|
||||
g.print(label, 10 + (bw - font:getWidth(label)) / 2, y + 9)
|
||||
y = y + bh + 12
|
||||
|
||||
-- what it will copy, shown in full and wrapped: the prompt is the
|
||||
-- product, so it is never hidden behind the button that makes it
|
||||
g.setFont(smallFont)
|
||||
g.setColor(0.65, 0.75, 0.85, 1)
|
||||
local preview = promptFor(mapId(), S.cx, S.cy)
|
||||
local _, lines = smallFont:getWrap(preview, panel - 20)
|
||||
for _, l in ipairs(lines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 8
|
||||
|
||||
g.setColor(0.55, 0.9, 0.6, 1)
|
||||
local _, outLines = smallFont:getWrap(
|
||||
("%d copied -> %s"):format(#S.copied, OUT), panel - 20)
|
||||
for _, l in ipairs(outLines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 5
|
||||
|
||||
if S.msg ~= "" then
|
||||
g.setColor(1, 0.9, 0.6, 1)
|
||||
local _, msgLines = smallFont:getWrap(S.msg, panel - 20)
|
||||
for _, l in ipairs(msgLines) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 13
|
||||
end
|
||||
y = y + 6
|
||||
end
|
||||
|
||||
if S.legend then
|
||||
y = y + 6
|
||||
g.setColor(0.75, 0.8, 0.9, 1)
|
||||
for _, l in ipairs(LEGEND) do
|
||||
g.print(l, 10, y)
|
||||
y = y + 14
|
||||
end
|
||||
end
|
||||
|
||||
if S.showPlan then
|
||||
local px = panel + 12
|
||||
drawPlan(px, 12, W - px - 12, H - 24)
|
||||
else
|
||||
-- and with nothing drawn there, nothing there is clickable: the mouse
|
||||
-- reads the rectangle the draw leaves behind, so clearing it is what
|
||||
-- stops a click on the game moving the cursor
|
||||
S.planRect = nil
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local innerDraw = love.draw
|
||||
function love.draw()
|
||||
if innerDraw then innerDraw() end
|
||||
if S.shooting then return end
|
||||
local g = love.graphics
|
||||
g.push("all")
|
||||
g.origin()
|
||||
local ok, err = pcall(drawOverlay)
|
||||
g.pop()
|
||||
if not ok and not S.drawWarned then
|
||||
S.drawWarned = true
|
||||
U.log("overlay draw failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the session
|
||||
|
||||
U.log("tile picker -- " .. #ids .. " maps")
|
||||
for _, l in ipairs(LEGEND) do U.log(" " .. l) end
|
||||
U.log("prompts append to " .. OUT)
|
||||
|
||||
local start = os.getenv("TILE_START")
|
||||
local at = 1
|
||||
if start and start ~= "" then
|
||||
for i, id in ipairs(ids) do
|
||||
if id == start then at = i break end
|
||||
end
|
||||
end
|
||||
enterMap(at)
|
||||
|
||||
local function dispatch(job)
|
||||
if job.cmd == "map" then
|
||||
enterMap(S.i + job.arg)
|
||||
elseif job.cmd == "shot" then
|
||||
shoot(job.arg == "hud")
|
||||
end
|
||||
end
|
||||
|
||||
-- One frame of the session: the next thing a key asked for, or a frame of
|
||||
-- standing still while the game runs underneath. Map changes and captures
|
||||
-- go through the queue rather than happening inside the key handler --
|
||||
-- both of them bind canvases, and doing that from inside love.keypressed
|
||||
-- is doing it inside the engine's own frame.
|
||||
local function pump(frames)
|
||||
for _ = 1, (frames or 1) do
|
||||
local job = table.remove(queue, 1)
|
||||
if job then dispatch(job) else step(1) end
|
||||
if S.quit then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- TILE_SELFTEST=1: the same session, with the keys pressed for you
|
||||
--
|
||||
-- Not a substitute for sitting in front of it -- the thing this tool is
|
||||
-- judged on is whether a bookshelf is recognisable in the inset -- but it
|
||||
-- proves the session starts, both wraps hold, every key path runs, a map
|
||||
-- change re-bakes, and a prompt reaches the clipboard and the file. Which
|
||||
-- is the whole of what can fail without a person noticing.
|
||||
if truthy(os.getenv("TILE_SELFTEST")) then
|
||||
OUT = DIR .. "/selftest_prompts.txt"
|
||||
local function press(key, frames)
|
||||
local ok, err = pcall(handle, key)
|
||||
if not ok then U.log("SELFTEST key " .. key .. " failed: " .. tostring(err)) end
|
||||
pump(frames or 20)
|
||||
end
|
||||
press("right")
|
||||
press("down")
|
||||
press("left")
|
||||
press("up")
|
||||
press("c") -- a prompt, copied
|
||||
press("z") -- the inset both ways
|
||||
press("z")
|
||||
press("g") -- each way of drawing the plan
|
||||
press("g")
|
||||
press("g")
|
||||
press("t") -- stand on it
|
||||
press("m") -- the plan away, and back -- with a shot of
|
||||
post("shot", "hud") -- the window without it, which is the only
|
||||
pump(240) -- thing that proves the panel stands alone
|
||||
press("m")
|
||||
press("]", 90) -- the next map, which re-bakes
|
||||
press("c") -- and a prompt from there
|
||||
press("[", 90) -- back
|
||||
press("y", 240) -- a screenshot of the world
|
||||
post("shot", "hud") -- and one WITH the panel, which is the only
|
||||
pump(240) -- thing that proves the overlay draws
|
||||
press("l") -- the list
|
||||
press("f1")
|
||||
-- the click paths, which the key presses above never touch
|
||||
local b = S.buttonRect
|
||||
if b then
|
||||
love.mousepressed(b[1] + 4, b[2] + 4, 1)
|
||||
pump(20)
|
||||
end
|
||||
local r = S.planRect
|
||||
if r then
|
||||
love.mousepressed(r.x + r.w * 0.5, r.y + r.h * 0.5, 1)
|
||||
pump(20)
|
||||
end
|
||||
U.log(("SELFTEST %d prompts, cursor %d,%d on %s -- %s")
|
||||
:format(#S.copied, S.cx, S.cy, mapId(), tostring(S.msg)))
|
||||
press("escape", 5)
|
||||
S.quit = true
|
||||
end
|
||||
|
||||
while not S.quit do pump(1) end
|
||||
|
||||
-- Hand the install back the way it was found.
|
||||
if S.tiles then pcall(S.tiles.release, S.tiles) end
|
||||
love.draw = innerDraw
|
||||
love.keypressed = innerKey
|
||||
love.mousepressed = innerMouse
|
||||
if wasRung then pcall(Pipelines.setLevel, "voxel", wasRung) end
|
||||
|
||||
if #S.copied > 0 then
|
||||
U.log(("-- %d prompt%s this session"):format(#S.copied,
|
||||
#S.copied == 1 and "" or "s"))
|
||||
for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end
|
||||
end
|
||||
U.log("done -- " .. OUT)
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Scratch driver: Celadon Gym's little tree ($40/$41 over $50/$51,
|
||||
-- one cell) at CELADON_GYM (5,7), flanked by the hedge cylinders.
|
||||
-- Front and back, voxel rung (5) and flat rung (3). Same spots BEFORE
|
||||
-- and AFTER the pin change.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tree_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/celtree AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/celtree")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[celtree] DRAMATIC_SHAPE is not loaded")
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
do -- prove the RUNNING mod sees the pin we think it does
|
||||
local TS = V.require("TileShape")
|
||||
local shapes = TS.forMap({ tileset = { id = "GYM",
|
||||
imageWidth = 128,
|
||||
imageHeight = 48 } })
|
||||
for _, t in ipairs({ 64, 80 }) do
|
||||
local s = shapes[t]
|
||||
print("[celtree] running-mod GYM tile " .. t .. ": "
|
||||
.. (s and (tostring(s.class) .. "/" .. tostring(s.art)
|
||||
.. " h=" .. tostring(s.h)) or "nil"))
|
||||
end
|
||||
end
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- the tree at (5,7) seen from the south = its front
|
||||
{ map = "CELADON_GYM", x = 5, y = 8, face = "up", label = "tree_front" },
|
||||
-- the same tree from the north = its back
|
||||
{ map = "CELADON_GYM", x = 5, y = 6, face = "down", label = "tree_back" },
|
||||
-- a step further back so the whole tree fits over the hedges
|
||||
{ map = "CELADON_GYM", x = 5, y = 9, face = "up", label = "tree_far" },
|
||||
-- from the open floor west of it, edge-on
|
||||
{ map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "tree_west" },
|
||||
-- the second placement at (7,5), unobstructed in the east garden
|
||||
{ map = "CELADON_GYM", x = 7, y = 7, face = "up", label = "tree2_front" },
|
||||
{ map = "CELADON_GYM", x = 8, y = 5, face = "left", label = "tree2_east" },
|
||||
{ map = "CELADON_GYM", x = 7, y = 3, face = "down", label = "tree2_back" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
for _, rung in ipairs({ 5, 3 }) do
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[celtree] capture missed: " .. path) end
|
||||
end
|
||||
else
|
||||
print("[celtree] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[celtree] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
+321
-18
@@ -680,6 +680,80 @@ TEMPLATES = {
|
||||
roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
|
||||
slab=3, front_eave=0, ledge=None, tileset="mansion",
|
||||
),
|
||||
# F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7)
|
||||
# (1 placement) -- the long table (F07) at two cells wide, the same
|
||||
# drawing to the tile everywhere but the interior column count, and
|
||||
# the same read to the row: 0-23 the tabletop seen from above, 24-26
|
||||
# the slab's black/#555/black front edge, 27 the #555 shadow that
|
||||
# closes it (slab = 3, folded into the band), 28-30 the base with the
|
||||
# legs stopping one row short of the grid. Family numbers unchanged.
|
||||
"mansion_square_table": dict(
|
||||
tiles=[
|
||||
[38, 39, 39, 41],
|
||||
[54, 55, 55, 57],
|
||||
[54, 55, 55, 57],
|
||||
[60, 58, 58, 59],
|
||||
],
|
||||
roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
|
||||
slab=3, front_eave=0, ledge=None, tileset="mansion",
|
||||
),
|
||||
# F07c: the Game Freak office's computer desk -- CELADON_MANSION_2F
|
||||
# cell (0,5) and CELADON_MANSION_3F cells (0,3), (3,3) and (0,6)
|
||||
# (4 placements, scan.lua, and the four ids of its apron and chair
|
||||
# row occur nowhere else on this atlas).
|
||||
#
|
||||
# Rows 0-15 are BILL'S DESK, byte for byte: the same tabletop seen
|
||||
# from above with the same terminal, cord and isometric computer
|
||||
# drawn into it (a whole-crop diff against the interior atlas's
|
||||
# 11/12/13/14 + 27/28/29/30 comes back empty for every one of the
|
||||
# 512 pixels). One drawing = one model, so those four parts are
|
||||
# bills_desk's verbatim -- see that entry for the readings.
|
||||
#
|
||||
# Only the FRONT differs, and it is drawn 6 rows where Bill's is 7:
|
||||
# 15 the top's own black front edge -- the row the lid
|
||||
# replaces, so it opens the fascia the way Bill's row 16
|
||||
# does and the desk stands 7 voxels rather than 8
|
||||
# 16-17 the #555 edge lip and the black seam under it: the
|
||||
# desktop's own rim, so `fascia` (it wraps every side)
|
||||
# 18-21 the base -- the left leg (the @#@ at x0-x2), the open
|
||||
# apron between, and the DRAWER PEDESTAL at x20-x31: two
|
||||
# #555 drawer fronts (rows 16-17 and 19-20) in a black
|
||||
# frame, which the measured recess pass sinks a voxel
|
||||
# each because they are non-black regions sealed behind
|
||||
# their own black outline. Row 21 is the ground line, and
|
||||
# the measured 22 is where the drawing puts the cast
|
||||
# shadow on the floor -- 22-23 are that shadow, dithered
|
||||
# floor in the walkable cell, and the model builds none
|
||||
# of it.
|
||||
# The chair is Bill's chair REDRAWN, not the same pixels (a 2px
|
||||
# white margin round the backrest panel where Bill's has 1px, and
|
||||
# it sits at x4-x15 rather than x2-x13), but the same object band
|
||||
# for band, so it takes that part table with x and rise adjusted:
|
||||
# rows 20-21 the backrest top seen from above, 22-31 its elevation,
|
||||
# rise = -7 putting it back on the floor, z 22 depth 10.
|
||||
"mansion_computer_desk": dict(
|
||||
tiles=[
|
||||
[36, 37, 52, 53],
|
||||
[64, 65, 66, 67],
|
||||
[2, 3, 85, 86],
|
||||
[18, 19, 17, 17],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="mansion", depth=4,
|
||||
# the desk's own plot is its two cells; the grid runs on because
|
||||
# its apron and the CHAIR share tiles 2/3
|
||||
desk=dict(fascia=(15, 17), base=(18, 21), depth=2),
|
||||
parts=[
|
||||
dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes
|
||||
dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13),
|
||||
z=8, depth=6), # the keyboard
|
||||
dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9),
|
||||
z=8, depth=2), # the cord
|
||||
dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9),
|
||||
dict(kind="upright", x=(4, 15), top=(20, 21), facade=(22, 31),
|
||||
rise=-7, z=22, depth=10), # the chair
|
||||
],
|
||||
),
|
||||
# F08: the dining table of the generic town house -- 18 placements,
|
||||
# every home's cells (3,3):(4,4) -- the chief's long table (F07) at
|
||||
# two cells wide. The same read to the row: 0-23 the rounded
|
||||
@@ -853,6 +927,79 @@ TEMPLATES = {
|
||||
z=3, depth=11, stretch=True), # the stool
|
||||
],
|
||||
),
|
||||
# F09 on the lobby atlas: the Celadon department store's stools --
|
||||
# the diner's chairs, the roof terrace's, the Game Corner's six
|
||||
# rows and the four on 1F (58 placements). A DIFFERENT drawing from
|
||||
# the house stool -- it sits one row HIGHER in the tile (seat top
|
||||
# rows 4-9 over front edge 10 and legs 11-14, with a clear floor
|
||||
# row below) and its leg detail differs -- but the same object band
|
||||
# for band, so it takes the same part table with the bands shifted
|
||||
# up one row. The measured ground line (15 here, 16 in the house)
|
||||
# shifts with them, so the stand is the same 5 voxels.
|
||||
"diner_stool": dict(
|
||||
tiles=[
|
||||
[7, 8],
|
||||
[23, 24],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="lobby",
|
||||
panes=False,
|
||||
parts=[
|
||||
dict(kind="upright", x=(2, 13), top=(4, 9), facade=(10, 14),
|
||||
z=3, depth=11, stretch=True), # the stool
|
||||
],
|
||||
),
|
||||
# F11: the ROUND TABLE of the Celadon diner and the roof terrace --
|
||||
# 4 placements, all on the lobby atlas (CELADON_DINER cells (0,2)
|
||||
# and (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan
|
||||
# "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55", no matches on
|
||||
# any other atlas). The drawing packs three facings no band split
|
||||
# can reach: rows 0-23 are the OCTAGONAL top seen from above (24
|
||||
# top-view rows = 24 depth rows, the same 1:1 every tabletop is
|
||||
# drawn with -- so the plan is the silhouette itself, 32 wide by 24
|
||||
# deep), rows 24-25 the slab's own fascia (#555 over black, folded
|
||||
# down the rim under the band's drawn outline -> slab 3), and rows
|
||||
# 26-31 the PEDESTAL seen under the front edge: the base's top
|
||||
# surface with the dark column rising from its middle (26-27), its
|
||||
# lit south half (28-29), and its front arc curving to the floor
|
||||
# (30-31). The flattened arcs are horizontal CIRCLES seen from
|
||||
# above -- depth, not narrowing -- so the pedestal is two discs:
|
||||
# base diameter 16 (drawn cols 8-23), column diameter 6 (the dark
|
||||
# blob's cols 13-18), both centred on the drawn centre x=16, plan
|
||||
# centre z=12. MEASURED: plan, diameters, centre, slab 3. AUTHORED:
|
||||
# tabletop plane 8 -- counter height, developer-tuned (the first
|
||||
# cut stood it at the flat compromise's 16px and it read too tall)
|
||||
# -- plus base height 2 and the 3-voxel column between (the
|
||||
# projection cannot state either; the drawn base arc suggests a
|
||||
# low disc). depth 3 keeps the plot to the drawn plan's 24 rows;
|
||||
# the grid's 4th tile row is the pedestal's own drawing and the
|
||||
# floor tile at its southeast corner, which the claim paints as
|
||||
# ground. `scrub` repoints the top's interior field -- the four
|
||||
# $37 tiles, which are ALSO the checkerboard floor's light half
|
||||
# and carry the floor's palette in a colorized atlas -- at the
|
||||
# same grey sourced from the rim's own field, so the whole top
|
||||
# wears the table's palette (the drawn field there is uniform
|
||||
# grey; nothing drawn is lost).
|
||||
"diner_round_table": dict(
|
||||
tiles=[
|
||||
[9, 39, 39, 25],
|
||||
[54, 55, 55, 57],
|
||||
[70, 55, 55, 71],
|
||||
[85, 86, 87, 55],
|
||||
],
|
||||
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||
slab=0, front_eave=0, ledge=None, tileset="lobby", depth=3,
|
||||
panes=False, scrub=[(8, 8, 23, 23)],
|
||||
parts=[
|
||||
dict(kind="disc", cx2=32, cz2=24, r=8, rise=0, h=2,
|
||||
side=dict(rows=(30, 31), x=(13, 18)),
|
||||
cap=dict(rows=(26, 29), x=(9, 22))), # the base
|
||||
dict(kind="disc", cx2=32, cz2=24, r=3, rise=2, h=3,
|
||||
side=dict(rows=(26, 27), x=(14, 17))), # the column
|
||||
dict(kind="plan", x=(0, 31), rows=(0, 23),
|
||||
fascia=(24, 25), fascia_x=(8, 23), rise=5), # the top
|
||||
],
|
||||
),
|
||||
# F04: the Pokemon Center's PC -- every Center's northeast corner
|
||||
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
|
||||
# shares this atlas. The lab desk-set read again: a
|
||||
@@ -1246,6 +1393,22 @@ def profile(sp, t):
|
||||
wall_h = ground - t["roof_rows"]
|
||||
ytop = wall_h - 1 + t["slab"]
|
||||
|
||||
# The row a column's roof SURFACE may sink to. `top[x]` is the
|
||||
# silhouette cap -- the black the drawing closes its shape with -- and
|
||||
# the depth map spends most of a tapered column's depth above it, so a
|
||||
# clamp onto `top[x]` paints that one outline pixel across the slope
|
||||
# and the courses beat against it. The surface belongs on the first
|
||||
# PAINTED row instead, which is the same thing the side faces already
|
||||
# do when the drawing's own pixel is the outline.
|
||||
surface_top = []
|
||||
for x in range(W):
|
||||
y = top[x]
|
||||
while (y < t["roof_rows"] and inside(x, y)
|
||||
and sp["col"][y][x] == BLACK):
|
||||
y += 1
|
||||
surface_top.append(y if y < t["roof_rows"] and inside(x, y)
|
||||
else top[x])
|
||||
|
||||
# Recesses: the panes the art seals behind a black frame. Non-black
|
||||
# pixels of the facade split into components across the black outline;
|
||||
# a component small enough to be a window or a doorway sinks one voxel.
|
||||
@@ -1287,7 +1450,8 @@ def profile(sp, t):
|
||||
# Depth is the PLOT. For a whole-drawing building that is the grid
|
||||
# itself; `depth` (in tile rows) names it when the grid runs past the
|
||||
# plot onto ground the drawing merely stands its legs on.
|
||||
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
|
||||
return dict(top=top, surface_top=surface_top,
|
||||
wall_h=wall_h, ytop=ytop, recess=recess,
|
||||
inside=inside,
|
||||
# `depth` names the plot in TILE ROWS, which is the right
|
||||
# grain for a building. `depth_px` names it in voxels, for
|
||||
@@ -1333,7 +1497,7 @@ def build_desk_set(sp, pr, t):
|
||||
|
||||
def build_parts(plane):
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
x0, x1 = p.get("x", (0, W - 1))
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
# `at` names the sheet's own height when it does not lie
|
||||
@@ -1445,6 +1609,91 @@ def build_desk_set(sp, pr, t):
|
||||
if pr0 <= sy <= pr1 and inside(sx, sy):
|
||||
put(sx, plane + y, z, sx, sy)
|
||||
continue
|
||||
if p["kind"] == "plan":
|
||||
# A PLAN part is a slab whose plan IS the drawn top view:
|
||||
# the band's silhouette becomes the footprint pixel for
|
||||
# pixel (drawn row = depth row, the same 1:1 every
|
||||
# tabletop is drawn with), so an octagonal top stands as
|
||||
# an octagon rather than the box no rectangular band can
|
||||
# escape. The top layer wears the band itself, outline
|
||||
# and all; the rim layers below wear the drawn fascia
|
||||
# rows folded down the edge (x clamped into the drawn
|
||||
# fascia's span), and the slab's unseen interior the
|
||||
# field's dark texel.
|
||||
r0, r1 = p["rows"]
|
||||
f0, f1 = p["fascia"]
|
||||
fx0, fx1 = p["fascia_x"]
|
||||
rise = p.get("rise", 0)
|
||||
h = (f1 - f0 + 1) + 1
|
||||
dark = shade_px.get(DARK) or shade_px[BLACK]
|
||||
|
||||
def drawn(sx, z):
|
||||
return (x0 <= sx <= x1 and 0 <= z <= r1 - r0
|
||||
and inside(sx, r0 + z))
|
||||
|
||||
for z in range(r1 - r0 + 1):
|
||||
if not 0 <= z < D:
|
||||
continue
|
||||
sy = r0 + z
|
||||
for sx in range(x0, x1 + 1):
|
||||
if not inside(sx, sy):
|
||||
continue
|
||||
put(sx, rise + h - 1, z, sx, sy)
|
||||
edge = not (drawn(sx - 1, z) and drawn(sx + 1, z)
|
||||
and drawn(sx, z - 1) and drawn(sx, z + 1))
|
||||
for y in range(rise, rise + h - 1):
|
||||
if edge:
|
||||
put(sx, y, z, max(fx0, min(fx1, sx)),
|
||||
f0 + (rise + h - 2 - y))
|
||||
else:
|
||||
put(sx, y, z, dark[0], dark[1])
|
||||
continue
|
||||
if p["kind"] == "disc":
|
||||
# A DISC part is ROUND IN PLAN -- the pedestal column
|
||||
# and base the projection can only draw from the front.
|
||||
# Centre and radius are measured off the drawn widths
|
||||
# (a flattened arc is a horizontal circle seen from
|
||||
# above); the circular footprint is synthesized like any
|
||||
# continued geometry, and every voxel still wears the
|
||||
# drawing: the side folds the drawn face-on rows around
|
||||
# the hull (x clamped into the drawn span, rows
|
||||
# repeating up the height), and `cap` lays the drawn
|
||||
# top-view rows over the top layer's interior, drawn
|
||||
# north rows to the plan's north. `cx2`/`cz2` are
|
||||
# DOUBLED plan centres, so an even diameter keeps its
|
||||
# centre between two voxels instead of limping one off.
|
||||
r, rise, h = p["r"], p.get("rise", 0), p["h"]
|
||||
s0, s1 = p["side"]["rows"]
|
||||
sa0, sa1 = p["side"]["x"]
|
||||
sn = s1 - s0 + 1
|
||||
cap = p.get("cap")
|
||||
|
||||
def in_disc(x, z):
|
||||
dx = 2 * x + 1 - p["cx2"]
|
||||
dz = 2 * z + 1 - p["cz2"]
|
||||
return dx * dx + dz * dz <= 4 * r * r
|
||||
|
||||
zlo = (p["cz2"] - 2 * r) // 2
|
||||
for x in range((p["cx2"] - 2 * r) // 2,
|
||||
(p["cx2"] + 2 * r) // 2 + 1):
|
||||
for z in range(max(0, zlo),
|
||||
min(D - 1, (p["cz2"] + 2 * r) // 2) + 1):
|
||||
if not in_disc(x, z):
|
||||
continue
|
||||
edge = not (in_disc(x - 1, z) and in_disc(x + 1, z)
|
||||
and in_disc(x, z - 1)
|
||||
and in_disc(x, z + 1))
|
||||
for y in range(rise, rise + h):
|
||||
if cap and y == rise + h - 1 and not edge:
|
||||
c0, c1 = cap["rows"]
|
||||
sy = min(c1, c0 + ((z - zlo)
|
||||
* (c1 - c0 + 1)) // (2 * r))
|
||||
sx = max(cap["x"][0], min(cap["x"][1], x))
|
||||
else:
|
||||
sy = s0 + (rise + h - 1 - y) % sn
|
||||
sx = max(sa0, min(sa1, x))
|
||||
put(x, y, z, sx, sy)
|
||||
continue
|
||||
tr0, tr1 = p["top"]
|
||||
fr0, fr1 = p["facade"]
|
||||
pd = p["depth"]
|
||||
@@ -1697,6 +1946,29 @@ def build_desk_set(sp, pr, t):
|
||||
return vox
|
||||
|
||||
|
||||
def roof_surface_row(pr, t):
|
||||
"""Which drawn row the roof surface wears at column x, depth z.
|
||||
|
||||
The drawing looks at the roof from the north, so its rows ARE depth: the
|
||||
rims map one row per voxel and the middle cycles a run whose period is
|
||||
the course rhythm. A tapered column's band starts lower down the
|
||||
drawing, and the row it wears is lifted to stay inside it."""
|
||||
z0, z1 = 0, pr["D"] - 1 + t["front_eave"]
|
||||
back, front = t["roof_back"], t["roof_front"]
|
||||
c0, c1 = t["roof_cycle"]
|
||||
rows, floor = t["roof_rows"], pr["surface_top"]
|
||||
|
||||
def depth_row(z):
|
||||
df, db = z - z0, z1 - z # from the north / the south edge
|
||||
if df < back:
|
||||
return df # north rim: the drawing's top rows
|
||||
if db < front:
|
||||
return rows - 1 - db # south rim: fascia and eave course
|
||||
return c0 + (df - c0) % (c1 - c0 + 1)
|
||||
|
||||
return (lambda x, z: max(depth_row(z), floor[x])), z0, z1
|
||||
|
||||
|
||||
def build(sp, pr, t):
|
||||
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
|
||||
the roof solid overwrites what it intersects and the walls are trimmed
|
||||
@@ -1775,17 +2047,7 @@ def build(sp, pr, t):
|
||||
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
|
||||
|
||||
# ---- roof: flat top over the plateau, stepped diagonal ends
|
||||
z0, z1 = 0, D - 1 + t["front_eave"]
|
||||
back, front = t["roof_back"], t["roof_front"]
|
||||
c0, c1 = t["roof_cycle"]
|
||||
|
||||
def roof_sy(z):
|
||||
df, db = z - z0, z1 - z # from the north / the south edge
|
||||
if df < back:
|
||||
return df # north rim: the drawing's top rows
|
||||
if db < front:
|
||||
return t["roof_rows"] - 1 - db # south rim: fascia and eave course
|
||||
return c0 + (df - c0) % (c1 - c0 + 1)
|
||||
surface_row, z0, z1 = roof_surface_row(pr, t)
|
||||
|
||||
shade_px = {}
|
||||
for sy in range(H):
|
||||
@@ -1797,10 +2059,7 @@ def build(sp, pr, t):
|
||||
tt = T(x)
|
||||
for z in range(z0, z1 + 1):
|
||||
outer = x == x0d or x == x1d or z == z0 or z == z1
|
||||
# the slope's texture is the drawing's own: clamping into the
|
||||
# column's first drawn row keeps flank battens running down the
|
||||
# slope instead of falling off the silhouette
|
||||
sy = max(roof_sy(z), pr["top"][x])
|
||||
sy = surface_row(x, z)
|
||||
for y in range(tt - slab + 1, tt + 1):
|
||||
if y == tt and not outer:
|
||||
put(x, y, z, x, sy)
|
||||
@@ -1933,7 +2192,7 @@ def verify_desk_set(vox, pr, t):
|
||||
# sink) -- and nothing stands anywhere else
|
||||
tops = {}
|
||||
for p in t["parts"]:
|
||||
x0, x1 = p["x"]
|
||||
x0, x1 = p.get("x", (0, W - 1))
|
||||
if p["kind"] == "flat":
|
||||
r0, r1 = p["rows"]
|
||||
z0 = p.get("z", r0)
|
||||
@@ -1979,6 +2238,37 @@ def verify_desk_set(vox, pr, t):
|
||||
for y in range(plane, plane + h + 1):
|
||||
assert (x, y, z) in vox, \
|
||||
f"iso box hole at {x},{y},{z}"
|
||||
elif p["kind"] == "plan":
|
||||
# its one geometric intent: the plan IS the drawn band's
|
||||
# silhouette -- a solid slab column wherever the band draws
|
||||
r0, r1 = p["rows"]
|
||||
h = (p["fascia"][1] - p["fascia"][0] + 1) + 1
|
||||
rise = p.get("rise", 0)
|
||||
for x in range(x0, x1 + 1):
|
||||
for z in range(min(r1 - r0 + 1, D)):
|
||||
if not pr["inside"](x, r0 + z):
|
||||
continue
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1)
|
||||
for y in range(rise, rise + h):
|
||||
assert (x, y, z) in vox, \
|
||||
f"plan slab hole at {x},{y},{z}"
|
||||
elif p["kind"] == "disc":
|
||||
# its one geometric intent: a solid circle in plan at every
|
||||
# layer, symmetric about the authored centre
|
||||
r, rise, h = p["r"], p.get("rise", 0), p["h"]
|
||||
in_disc = lambda x, z: ((2 * x + 1 - p["cx2"]) ** 2
|
||||
+ (2 * z + 1 - p["cz2"]) ** 2
|
||||
<= 4 * r * r)
|
||||
for x in range((p["cx2"] - 2 * r) // 2,
|
||||
(p["cx2"] + 2 * r) // 2 + 1):
|
||||
for z in range(D):
|
||||
if not in_disc(x, z):
|
||||
continue
|
||||
assert in_disc(p["cx2"] - 1 - x, z), \
|
||||
f"disc asymmetric at {x},{z}"
|
||||
tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1)
|
||||
for y in range(rise, rise + h):
|
||||
assert (x, y, z) in vox, f"disc hole at {x},{y},{z}"
|
||||
else:
|
||||
fr0, fr1 = p["facade"]
|
||||
ytp = plane + p.get("rise", 0) + (fr1 - fr0)
|
||||
@@ -2043,6 +2333,19 @@ def verify_roof(vox, pr, t):
|
||||
# drawing's own corner rounding
|
||||
assert all(ytop - v <= 1 for v in prof), "the flat roof is not level"
|
||||
|
||||
# The surface is surface, not outline. `top[x]` is the column's own
|
||||
# silhouette cap -- the black the drawing closes the shape with, the
|
||||
# same black measure() already refuses to let stand as a wall's side
|
||||
# face -- so a surface that samples it promotes a boundary decoration
|
||||
# into texture. A tapered column's cap sits well down the band, where
|
||||
# the depth map spends most of the roof's depth above it.
|
||||
surface_row, sz0, sz1 = roof_surface_row(pr, t)
|
||||
for x in roofed[1:-1]:
|
||||
for z in range(sz0 + 1, sz1):
|
||||
assert surface_row(x, z) != top[x], \
|
||||
f"roof surface wears the silhouette cap at {x},{z} " \
|
||||
f"(row {top[x]})"
|
||||
|
||||
# every wall column carries roof over it -- and a column the roof never
|
||||
# reaches carries nothing at all, rather than being silently trimmed away
|
||||
over = set(roofed)
|
||||
|
||||
+11
-11
@@ -229,11 +229,9 @@ def stance(data):
|
||||
Measured on the BIND POSE, which is the one pose in the set that can be
|
||||
trusted for this. Two things recommend it. It reproduces the verified
|
||||
glTF export bit for bit on all 151 species, so it is measuring the same
|
||||
skeleton the reference implementation agreed with; and it is immune to
|
||||
the animation quirks a handful of species carry (Exeggutor's idle throws
|
||||
limbs hundreds of units off the body from frame 1 on, and Magmar's does
|
||||
something similar) -- quirks that would otherwise decide how big every
|
||||
OTHER frame of those species is drawn.
|
||||
skeleton the reference implementation agreed with; and it does not move
|
||||
with the animations, so no single clip's excursion decides how big every
|
||||
other frame of that species is drawn.
|
||||
|
||||
The floor is the interesting number, and it reads cleanly: 119 of the
|
||||
151 sit within 5% of zero, which says the model origin IS where the game
|
||||
@@ -249,13 +247,15 @@ def stance(data):
|
||||
|
||||
|
||||
def idle_is_broken(data, idle):
|
||||
"""Whether this species' standby loop is corrupt in the source data.
|
||||
"""Whether this species' standby loop is corrupt as extracted.
|
||||
|
||||
A handful of species come out of the extraction with animations that
|
||||
throw bones hundreds of units off the body -- Exeggutor, Tangela and
|
||||
Magmar, whose channel streams the game's own index arithmetic evidently
|
||||
reads differently from the way the exporter does. Played, they look
|
||||
like a Pokemon coming apart; the mod would rather stand them still.
|
||||
No species trips this today. Exeggutor, Tangela and Magmar used to:
|
||||
their animations are hermite keyframes (flags & 8), the extractor read
|
||||
the flags byte at +0 -- the always-zero high half of the u16 -- and
|
||||
decoded keyframe tables as packed streams, which threw bones hundreds
|
||||
of units off the body. The detector stays as the guard against the
|
||||
next extraction bug: played, a broken idle looks like a Pokemon coming
|
||||
apart, and the mod would rather show the sprite fallback.
|
||||
|
||||
The test is deliberately narrow, because "differs from the bind pose" is
|
||||
NOT brokenness. It is asked only of the STANDBY loop, which is the one
|
||||
|
||||
Reference in New Issue
Block a user