mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:40:51 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bcd4d3144 | |||
| b50bbe0782 | |||
| 9f74ea7e73 | |||
| 442e9d26d5 | |||
| 6240b50cec | |||
| 790c34efff | |||
| e14cf3de90 | |||
| 77e0f93315 | |||
| 20c9061625 | |||
| dde0879527 | |||
| 9e54656fe3 | |||
| c6b38f8d44 |
@@ -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
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
@@ -213,8 +213,6 @@ alongside.
|
||||
|
||||
## 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
|
||||
|
||||
+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,
|
||||
},
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -303,7 +303,11 @@ 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
|
||||
@@ -475,6 +479,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 --
|
||||
|
||||
+28
-5
@@ -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 }
|
||||
@@ -882,6 +903,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 +953,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
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
-- 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 = {}
|
||||
|
||||
-- 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;
|
||||
|
||||
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;
|
||||
}
|
||||
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 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);
|
||||
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, "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, "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
|
||||
@@ -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
|
||||
|
||||
+15
-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
|
||||
|
||||
+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",
|
||||
|
||||
@@ -72,6 +72,7 @@ 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_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 +88,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 +110,19 @@ 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);
|
||||
}
|
||||
// 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
|
||||
@@ -205,6 +220,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
|
||||
@@ -256,6 +272,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
|
||||
@@ -687,6 +708,14 @@ 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 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
|
||||
@@ -922,6 +951,12 @@ 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 })
|
||||
-- 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 +1162,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.
|
||||
|
||||
@@ -914,6 +914,12 @@ 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
|
||||
|
||||
local function atlasFor(map)
|
||||
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
|
||||
@@ -1152,6 +1158,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
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
|
||||
|
||||
+24
-2
@@ -940,7 +940,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
|
||||
@@ -1133,7 +1142,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 +1150,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 +1174,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
|
||||
|
||||
@@ -90,6 +90,7 @@ 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")
|
||||
@@ -191,6 +192,9 @@ 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)
|
||||
-- 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,6 +306,7 @@ 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
|
||||
end,
|
||||
})
|
||||
@@ -427,6 +432,18 @@ local SETTINGS = {
|
||||
.. "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
|
||||
@@ -914,6 +931,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
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.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"
|
||||
],
|
||||
|
||||
@@ -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
+209
-11
@@ -319,7 +319,7 @@ 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,
|
||||
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 5,
|
||||
"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 +404,23 @@ 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, 11, "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 battles, backRow, daytime = hookedRows[6], hookedRows[7], hookedRows[8]
|
||||
-- the FOREST FX row is hookedRows[5] and AA hookedRows[9]; both 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(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[5].label, "FOREST FX", "the atmosphere row carries its label")
|
||||
T.eq(hookedRows[5].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")
|
||||
@@ -499,7 +504,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[9]
|
||||
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 +2914,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 +4069,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
|
||||
@@ -5581,11 +5775,15 @@ 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)()
|
||||
|
||||
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,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,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
|
||||
}
|
||||
|
||||
|
||||
+56
-16
@@ -1246,6 +1246,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 +1303,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
|
||||
@@ -1697,6 +1714,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 +1815,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 +1827,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)
|
||||
@@ -2043,6 +2070,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