Compare commits

...

12 Commits

Author SHA1 Message Date
DramaticShape 917fbac38c Merge pull request #7 from DramaticShape/3d-battle-mod
fix white screen flash on shake on versions > 1.3.0
2026-07-28 16:16:45 -04:00
DramaticShape d8d1d7e336 fix white screen flash on shake on versions > 1.3.0 2026-07-28 16:10:58 -04:00
DramaticShape 4e6aa30c2c Merge pull request #6 from DramaticShape/3d-battle-mod
fixed full screen flashing in battles
2026-07-28 15:22:35 -04:00
DramaticShape 2e0ae37bcd fixed full screen flashing in battles 2026-07-28 15:22:13 -04:00
DramaticShape 9207712b19 Merge pull request #5 from DramaticShape/3d-battle-mod
set FULL tilt to 35
2026-07-28 14:01:54 -04:00
DramaticShape 2447046aee set FULL tilt to 35 2026-07-28 14:00:03 -04:00
DramaticShape 458a1d7bf9 Merge pull request #4 from DramaticShape/3d-battle-mod
3d battle mod
2026-07-28 13:38:39 -04:00
DramaticShape 5781c28b60 added FULL menu option for voxel mod 2026-07-28 13:37:19 -04:00
DramaticShape 6280cdffe7 Added overworld battles 2026-07-28 13:01:38 -04:00
DramaticShape acc66b51bb Merge pull request #3 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:19:36 -04:00
DramaticShape 894a4b4a55 Merge pull request #2 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:13:57 -04:00
DramaticShape 2a4269b20e Merge pull request #1 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 04:58:07 -04:00
23 changed files with 4351 additions and 54 deletions
+3
View File
@@ -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_pick.lua
tests/battle_shots.lua
tests/dramatic_shape_test.lua
tests/voxel_anim_probe.lua
tests/voxel_door_probe.lua
@@ -19,6 +21,7 @@ tests/voxel_void_probe.lua
# ROM, read the local cache, and emit assets/voxels/*.lua; the carved models
# are what a player installs, the carving is not.
tools/build_voxels.py
tools/contact_sheets.py
tools/building_images.py
tools/building_voxels.py
tools/voxel-survey.md
+105 -1
View File
@@ -1,6 +1,110 @@
# Changelog
## Unreleased
## 1.1.1
### Fixed
- A move that shakes the screen no longer whites out the frame. The zone pass
fills each zone with its blank colour before drawing the shifted copy -- the
hardware showing empty BG in the strip the shake vacated -- and a shake
program alternates offset and no-offset frames, so over the map that read as
the whole battle screen, menu box included, flashing white a few times a
second. The fill is dropped while a battle is staged on the map; the shake
itself still moves the HUD.
## 1.2.0
### Added
- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
puts the whole mode in its intended state -- the 35-degree camera, the
miniature blur at maximum, the horizon flat, the view fitted, and battles
on the map -- rather than making a player assemble it from four rows.
While it is selected, every row it owns comes OFF the menu: V-GRID,
V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is
worse than no row. Stepping onto or off `FULL` rebuilds the open menu in
place, so the rows leave and return under the cursor instead of waiting
for the menu to be reopened.
It applies its settings when the row ARRIVES at `FULL`, not every frame:
holding them would make the zoom keys and the wheel dead while it was on.
Leaving it deliberately undoes nothing -- reverting would discard whatever
had been changed since.
### Fixed
- **The hit flash whited out the whole screen.** The engine draws it as a
full-screen white rectangle, which is a flash on a white battle field and
a whiteout of the map, the HUD and the text box over a world. It is now
dropped on the way past and put back where it was ever about: the two
Pokemon go solid white for those frames, silhouette and all, and nothing
else in the frame moves.
### Changed
- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
a display-mode cycler -- it should change the camera and nothing else --
and `FULL` reaches in and rewrites four other settings. Landing on it
mid-walk would silently push the blur to maximum and flatten the horizon
with nothing on screen saying a keypress had done it. `FULL` stays on the
OPTIONS row, where a preset that changes other rows belongs.
A press FROM `FULL` goes to `50`. `FULL` is already the 35-degree camera,
so stepping to the rung of that name would look like the key had done
nothing. Matched by angle, so it follows `FULL` if that is ever retuned.
- **The mode's four options are one block in the menu.** The engine splices
a pipeline row in beside TILT and lands a mod's own rows at the end of the
list, which had these four in two places with unrelated engine rows
between them. The settings now follow the pipeline rows directly.
## 1.1.0
### Added
- **Battles happen on the map you were standing on.** The battle screen's
white field is replaced by the world: the mod finds the nearest patch of
open ground, points a placed over-the-shoulder camera at it, and draws the
fight over that. New **3D-BTL** row and hotkey `8`, on by default.
The arena is a 3x6 clearing of cells the player could walk on, with the
two mons three cells apart down the middle column and a one-cell apron all
round so the camera looks across floor rather than into a wall. Where no
map has room for that -- a corridor, a cave, a shop -- the search relaxes
to a 1x4 corridor with the apron given up, and where even that will not
fit the battle draws exactly as it always did.
Everything else in the frame is the engine's own. The mon pics, HUDs, HP
bars, move animations, faint slides and text box are drawn by BattleState,
in its order, at its coordinates -- the GB's own layout, with the player's
mon low and left and the enemy's high and right, which is why the camera
is placed east of the arena axis rather than the layout being moved to
suit the camera. What changes is what is behind them.
Three things carry the shot. The overworld's cast is culled before the
wipe, so it plays over an empty map and no bystander is standing in the
arena. The camera drifts on a slow orbit about a point between the two
mons, which moves the near ground and the far ground by different amounts
-- parallax, not a sliding backdrop. And a depth-of-field pass holds the
band of frame the two mons stand in sharp and softens the middle distance
and the foreground; both mons are in focus by construction, because they
are drawn as the battle screen's own pics after the pass has run.
**Nobody moves.** The arena is where the CAMERA goes. Nothing here writes
a cell, a facing, a flag or a warp, so a trainer's post-battle dialogue is
still talking to someone standing in front of them, and the blackout path,
sight lines and every script find the player exactly where they left them.
The two HUD blocks gain the backing the white field used to be. Gen 1
draws them as black glyphs straight onto the background with no box round
them, and black-on-grass is not readable; the backing is painted inside
`drawHUDs`, so it lands in the same target and takes the same zone colour
as the HUD it sits under, in both the colorized and flat pipelines.
Declines cleanly at every step it cannot take: no depth support, no open
ground, the row switched off, or a terrain mesh still building all end at
the battle screen the engine has always drawn.
### Changed
+46 -2
View File
@@ -9,10 +9,18 @@ as leaning sprite slabs, a shadow map throws real cast shadows across
whatever they land on, and an optional tilt-shift pass sells the
miniature-model look.
And battles fought on that world rather than on a white field. When
something picks a fight the map's NPCs are culled, the engine's own wipe
plays over the empty map, and the battle draws over the nearest patch of
clear ground — shot over the shoulder, the player's mon low and left and
the enemy high and right, with a slow parallax drift behind them and a
depth-of-field pass that keeps both of them sharp.
Purely presentational. Nothing here reaches collision, movement, triggers
or scripts — it changes what the world *looks* like and nothing about what
it *is*. Battles, menus and cutscenes are untouched; only the free-roam
overworld draws differently.
it *is*. The battle arena is where the **camera** goes, not where anybody
goes: no cell, facing, flag or warp is written, so the player is standing
exactly where the fight found them when it ends.
## Controls
@@ -25,6 +33,42 @@ menu.
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
## Where a battle is staged
The mod looks for the nearest clearing shaped like this, where every `x` is
a cell the player could walk onto — the two mons three cells apart down the
middle, with a one-cell apron so the camera looks across floor rather than
into a wall:
```
x x x
x O x O the enemy's mon
x x x
x x x
x P x P your mon
x x x
```
Indoors, in a corridor or in a cave there is often no room for that, so the
search relaxes to the same three-cell gap with the apron given up:
```
O
x
x
P
```
If neither will fit — and on a map with no open ground at all, or on a
machine with no depth-buffer support — the battle draws exactly the way it
always did. Water counts as open ground while you are surfing and not
otherwise, and warp tiles never count, so a fight is never framed in a
doorway.
Two of those keys are taken off the engine: `3` was **TILT** and `5` was
**GBC FX**. Neither is reachable by key while this mod is enabled, and both
+186
View File
@@ -0,0 +1,186 @@
-- Where each map's battles happen.
--
-- One authored spot per area, rather than whichever clearing happens to be
-- nearest the step the fight started on. A battle on Route 1 should look the
-- same every time it happens on Route 1 -- and, more importantly, "open
-- ground" is not the same question as "you can see the two of them": the
-- battle camera sits low and a long way back, so a hedge, a ledge lip or the
-- corner of a house anywhere along that sightline hides a Pokemon completely
-- while every cell it is standing on is perfectly walkable.
--
-- Each entry is the arena's north-west corner in map CELLS, and which of
-- BattleArena.SHAPES it is. Picked by tools/arena_pick (BattleArena.search
-- with the clearance test on, from the middle of the map) and then looked at,
-- one screenshot per map -- these are eyeballed answers, not just passing
-- ones. Grass and flowers around a mon's feet are fine and wanted; anything
-- that cuts into a body is not.
--
-- A map with no entry here falls back to the nearest-clear search at battle
-- time, so a mod that adds maps, or an entry that goes stale against an
-- edited map, degrades to the old behaviour rather than to no battle.
--
-- The entries below are generated; regenerate with
--
-- SHOT_DIR=.scratchpad/arenas \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
-- `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
-- on the same screen anchors either way -- so it is purely a choice about how
-- much of the place is in shot, at the cost of a smaller pair. Rooms the long
-- lens cannot stand back from NEED it; anywhere that simply reads better with
-- more of itself visible may ask for it.
--
-- Tall grass is never part of an arena (BattleArena.openCell rejects it), so
-- a route's spot is always its bare path rather than the field beside it --
-- grass is knee-high geometry to a Pokemon and this camera is nearly level
-- with the floor, so tufts on the mon's own tile stand between it and the
-- lens. Flowers are left alone: they are ankle height and read as ground.
return {
-- ------- routes
["ROUTE_1"] = { x = 0, y = 16, shape = "wide" },
["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" },
-- 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
-- terrace through the near mon's waist, which it does not.
["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_17"] = { x = 14, y = 70, shape = "wide" },
["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
-- ------- buildings and caves
--
-- Indoors the narrow shape earns its keep: a room with furniture, machinery
-- 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_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
-- mon; the floor below is the same tower and has the room, so the fight
-- 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_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" },
["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" },
["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" },
-- 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_5F"] = { x = 16, y = 7, shape = "wide" },
["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
-- aboard and the long lens always lands outside the hull
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
-- these two decks are byte-identical geometry, so they take the same spot
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
-- ------- towns and the last interiors
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
-- the lab is ten cells by twelve, so the long lens is always off-map, and
-- on it a desk clipped one mon and a pillar the other
["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
-- Saffron's gym is a grid of small walled cells: no wide shape exists
-- anywhere in it, and the long lens sits inside a divider
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
-- gym's barrier, so arenas there silently fail the fit test
["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
-- ------- the remaining routes
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
-- than on the rim of beach the land search would otherwise find
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
-- ------- caves, gyms and the Elite Four
--
-- 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" },
-- 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" },
["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" },
-- 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
-- the border ring -- extruded into a cliff by this mode -- crossed the near
-- mon wherever it stood. `cam = "wide"` swaps in the 44-degree lens (see
-- 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" },
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
}
+360
View File
@@ -0,0 +1,360 @@
-- Overworld battles: where the fight is staged.
--
-- A battle in this mod happens ON THE MAP, so it needs a patch of ground
-- clear enough to stand two Pokemon on and point a camera down. This module
-- finds it: the nearest patch of open cells, in the shape below.
--
-- x x x
-- x O x O the enemy's mon
-- x x x
-- x x x
-- x P x P the player's mon
-- x x x
--
-- Every `x` is an OPEN cell -- one with no obstruction, i.e. one the player
-- could walk onto. The two mons stand three cells apart down the middle
-- column, with a one-cell apron all round so the camera looks across floor
-- rather than into a wall.
--
-- When no map has room for that -- a corridor, a cave, a shop floor -- the
-- search relaxes to the narrow shape, which is the same three-cell gap with
-- the apron given up:
--
-- O
-- x
-- x
-- P
--
-- and if even that will not fit, the caller gets nil and the battle draws
-- the way it always did. A mod that cannot find a stage does not invent
-- one.
--
-- Nothing here MOVES anybody: the arena is where the CAMERA goes and where
-- the two mons are staged for the shot. The player's own cell, the party,
-- every script and flag are exactly where the battle left them, which is
-- what keeps a trainer's post-battle dialogue talking to someone still
-- standing in front of them.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleArena = {}
-- ------- the authored spot
--
-- Every map gets ONE place its battles happen, chosen once and written down
-- in data/battle_arenas.lua, rather than whatever clearing happens to be
-- nearest to wherever the fight started. Two reasons.
--
-- A fight should look the same every time it happens somewhere. Picking the
-- nearest patch means Route 1 has a dozen different battle scenes depending
-- on which step of the grass you were on, some of them behind a tree.
--
-- And "open ground" is not the same question as "you can SEE the two of
-- them". The camera is low and a long way back, so a hedge, a ledge lip or a
-- building corner anywhere along that line hides a mon completely while the
-- cells it stands on are perfectly walkable. That is what `clearance` below
-- measures, and it is what the authored list is chosen against.
--
-- A map with no entry falls back to the search, so a mod that adds maps, or
-- an entry that goes stale, degrades to the old behaviour rather than to no
-- battle.
-- An entry may also name ANOTHER MAP to stage on:
--
-- ["MT_MOON_B2F"] = { map = "MT_MOON_1F", x = 12, y = 8, shape = "wide" }
--
-- because some maps simply have nowhere to put a fight. A cave's lower floor
-- can be nothing but two-cell-wide corridors between rock walls; a gym is a
-- room full of furniture. Rather than stage a battle there badly -- both
-- Pokemon behind a boulder -- the fight is shot on a floor of the SAME cave,
-- or a floor of the same building, that does have the room for it. It is the
-- same place, and no worse a fiction than a battle happening on ground the
-- player is not standing on, which is what every one of these already is.
local authored = nil
local overrides = {}
local function authoredFor(mapId)
-- `~= nil`, not truthiness: `false` is a meaningful entry here (an
-- authored refusal), so it has to reach the caller rather than read as
-- "nothing set" and fall through to the data file
local forced = overrides[mapId]
if forced ~= nil then return forced end
if authored == nil then
local ok, list = pcall(V.data, "battle_arenas")
authored = (ok and type(list) == "table") and list or false
end
if not authored then return nil end
return authored[mapId]
end
BattleArena.authoredFor = authoredFor
-- Force one map's entry at runtime, ahead of the data file. The authoring
-- tool's handle: it is how a spot chosen by eye is staged and photographed
-- before it is written down, and the only way to check a cross-floor entry
-- without editing the shipped list first. Pass nil to drop it again.
function BattleArena.setOverride(mapId, entry)
overrides[mapId] = entry
end
-- Cell size in world pixels, the unit every coordinate here is in when it
-- crosses into the renderer (Map's walk grid is 16px cells).
local CELL = 16
-- The two shapes, in preference order. `w`/`h` are in cells; `enemy` and
-- `player` are the offsets, from the shape's north-west corner, of the two
-- cells a mon stands on.
BattleArena.SHAPES = {
{ id = "wide", w = 3, h = 6, enemy = { 1, 1 }, player = { 1, 4 } },
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
}
-- Whether a cell is open ground for the purpose above.
--
-- "Open" is the walk test the player themselves answer to, so an arena can
-- never be laid over a wall, a counter, a tree or a ledge face. Water counts
-- only for a surfer, which is the one case where the player is standing on
-- it too -- a sea battle staged on the beach half a route away would read as
-- a teleport.
--
-- Warp cells are excluded on top of that. They are walkable by definition
-- (they are the doormat), and a fight framed in a doorway both looks wrong
-- and puts the camera inside the building's geometry.
--
-- And TALL GRASS is excluded, which is the surprising one, because grass is
-- where wild battles come from and standing in it is the obvious place to
-- have one. It does not survive contact with the camera. Grass is real
-- geometry in this mode -- a row of tufts about knee height on a Pokemon --
-- drawn with the same camera-ward bias that lets it overdraw a walking
-- character's feet in the free-roam world. From a camera nearly level with
-- the floor that bias stops being feet-deep: the tufts on and around a mon's
-- own tile stand between it and the lens and eat most of the sprite.
--
-- So the arena is laid on bare ground -- the whole footprint, not just the
-- two cells a mon stands on, because the apron south of the near mon is
-- exactly the row whose grass would cover it. Grass FURTHER back toward the
-- camera is fine and stays: it is far enough forward to project low and wide
-- across the bottom of the frame, where it reads as a field rather than as
-- something in the way.
local function openCell(map, cx, cy, surfing)
if not map:inBounds(cx, cy) then return false end
if map:warpAtCell(cx, cy) then return false end
if map:isWarpTileCell(cx, cy) then return false end
if map.isGrassCell and map:isGrassCell(cx, cy) then return false end
if map:isWalkableCell(cx, cy) then return true end
return (surfing and map:isWaterCell(cx, cy)) or false
end
BattleArena.openCell = openCell
-- The map's open cells as one flat boolean grid, so the rectangle test
-- below is a lookup rather than a tileset walk per cell. Built once per
-- search; a battle asks for one.
local function openGrid(map, surfing)
local w, h = map.widthCells, map.heightCells
local grid = {}
for cy = 0, h - 1 do
local row = cy * w
for cx = 0, w - 1 do
grid[row + cx] = openCell(map, cx, cy, surfing)
end
end
return grid, w, h
end
local function fits(grid, gw, x, y, w, h)
for cy = y, y + h - 1 do
local row = cy * gw
for cx = x, x + w - 1 do
if not grid[row + cx] then return false end
end
end
return true
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 arena = {
shape = shape.id,
x = x, y = y, w = shape.w, h = shape.h,
enemyCell = { ex, ey },
playerCell = { px, py },
-- world-pixel centres of the two cells a mon stands on
enemy = { ex * CELL + CELL / 2, ey * CELL + CELL / 2 },
player = { px * CELL + CELL / 2, py * CELL + CELL / 2 },
}
arena.mid = { (arena.enemy[1] + arena.player[1]) / 2,
(arena.enemy[2] + arena.player[2]) / 2 }
return arena
end
-- ------- can the two of them actually be SEEN there
--
-- The camera sits low and far back on one side, so what hides a mon is not
-- what is on its own tile -- it is anything TALL between the camera and it.
-- A tree two cells to the south-east blocks the near mon completely while
-- every cell of the arena is open ground.
--
-- So the line from the eye to each mon is walked in short steps and the
-- terrain height under each step is compared with how high the line is
-- there. Three lines per mon -- to its feet, its middle and its head --
-- because a hedge that clears the head still cuts the body in half.
--
-- Grass and flowers are deliberately not obstacles: they stand at ankle
-- height, they are what a field looks like, and a mon standing in them
-- reads as standing in a field rather than as being hidden by one.
BattleArena.SAMPLE_STEP = 4 -- world pixels along the line
BattleArena.MON_H = 16 -- how tall a mon stands, in world pixels
BattleArena.CLEAR_EPS = 1.5 -- slack, so a flush kerb is not an obstacle
local function heightAt(map, wx, wz)
local cx, cy = math.floor(wx / CELL), math.floor(wz / CELL)
if not map:inBounds(cx, cy) then
-- off the map the border ring is drawn, and on most outdoor maps that
-- ring is trees; treat it as solid so an arena is never framed through it
return 32
end
local ok, h = pcall(V.require("VoxelScene").groundAt, map, cx, cy)
return (ok and h) or 0
end
-- Whether the segment from `eye` to (tx, ty, tz) clears the terrain.
local function lineClear(map, eye, tx, ty, tz)
local dx, dy, dz = tx - eye[1], ty - eye[2], tz - eye[3]
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len <= 1 then return true end
local steps = math.ceil(len / BattleArena.SAMPLE_STEP)
-- skip the ends: the eye is in open air by construction and the last step
-- is the mon's own tile, which it is standing on
for i = 1, steps - 1 do
local t = i / steps
local wx = eye[1] + dx * t
local wy = eye[2] + dy * t
local wz = eye[3] + dz * t
if heightAt(map, wx, wz) > wy + BattleArena.CLEAR_EPS then return false end
end
return true
end
-- Whether both mons would be in plain view from the battle camera.
function BattleArena.clearance(map, arena)
local BattleCam = V.require("BattleCam")
local ok, rig = pcall(BattleCam.rig, arena, 0)
if not (ok and rig and rig.eye) then return true end
local eye = rig.eye
local H = BattleArena.MON_H
for _, mark in ipairs({ arena.player, arena.enemy }) do
for _, hy in ipairs({ 1, H * 0.5, H }) do
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
end
end
return true
end
-- The nearest arena to (fromX, fromY) -- the player's cell -- or nil when
-- the map has room for neither shape.
--
-- Distance is measured from the player to the arena's MIDPOINT, so "nearest"
-- means the fight is staged as close to where it was triggered as the ground
-- allows, rather than merely having a corner nearby.
--
-- Both shapes are searched over the whole map before the next one is tried:
-- a wide arena on the far side of a route still beats a narrow one
-- underfoot, because the wide one is the shot this mode is framed for.
function BattleArena.find(map, fromX, fromY, surfing)
if not (map and map.widthCells) then return nil end
-- the authored spot wins outright when the map has one and it still holds
local pick = authoredFor(map.id)
-- `false` is an authored REFUSAL: a map looked at and found to have nowhere
-- a fight can be seen, with no other floor to borrow. Declining is the
-- honest answer -- the battle draws on the plain screen -- and it has to be
-- said explicitly, because the fallback search below would otherwise go and
-- find one of the bad spots that were already rejected by eye.
if pick == false then return nil end
if pick then
local shape = nil
for _, s in ipairs(BattleArena.SHAPES) do
if s.id == (pick.shape or "wide") then shape = s end
end
-- an entry may point at another floor of the same cave or building; the
-- arena is then measured against THAT map, and carries it
local host = map
if shape and pick.map and pick.map ~= map.id then
local ok, other = pcall(function()
local Game = require("src.core.Game")
return require("src.world.MapLoader").load(Game.data, pick.map)
end)
host = (ok and other) or nil
end
if shape and host then
-- An authored spot is checked with WATER COUNTING AS GROUND, whatever
-- the player is doing. The surfing test exists to stop the automatic
-- search staging a walker's fight out at sea; an authored entry was
-- chosen and looked at by a person, so if it is on water that is the
-- point of it -- the surf routes fight in the middle of their own
-- 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)
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)
arena.cam = pick.cam
return arena
end
end
end
local found = BattleArena.search(map, fromX, fromY, surfing)
if found then found.map = map end
return found
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)
for _, shape in ipairs(BattleArena.SHAPES) do
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
end
return nil
end
-- The nearest arena the map can offer, preferring one the pair can be SEEN
-- in. Two passes rather than one score: a clear arena on the far side of a
-- route beats an obstructed one underfoot, because being able to see the
-- fight is the point, but an obstructed one still beats no battle at all.
function BattleArena.search(map, fromX, fromY, surfing, wantClear)
local grid, gw, gh = openGrid(map, surfing)
for _, shape in ipairs(BattleArena.SHAPES) do
for _, needClear in ipairs({ true, false }) do
local best, bestD = nil, nil
for y = 0, gh - shape.h do
for x = 0, gw - shape.w do
if fits(grid, gw, x, y, shape.w, shape.h) then
local mx = x + (shape.w - 1) / 2
local my = y + (shape.h - 1) / 2
local dx, dy = mx - fromX, my - fromY
local d = dx * dx + dy * dy
if not bestD or d < bestD then
local cand = place(shape, x, y)
if not needClear or BattleArena.clearance(map, cand) then
best, bestD = cand, d
end
end
end
end
end
if best then return best end
if wantClear and needClear then return nil end
end
end
return nil
end
return BattleArena
+120
View File
@@ -0,0 +1,120 @@
-- Overworld battles: the two mons, as geometry standing on the map.
--
-- Not pics composited over a picture of the world -- quads INSIDE it, drawn
-- in the same 3D pass as the terrain, from the same camera, through the same
-- shader. Which means they get everything the world gets and nothing has to
-- be faked for them: the depth buffer decides what is in front of what, the
-- sun pass sees them and throws their real silhouettes across the ground,
-- and their size on screen is whatever standing on that tile at that
-- distance actually looks like.
--
-- One quad, standing upright with its feet on the cell and yawed to face the
-- camera. Upright rather than leaned back, unlike the free-roam mode's
-- character cards: those lean because that camera looks DOWN and a standing
-- card would foreshorten to nothing, and this one looks along the ground
-- from about a foot above it, where a card standing up is simply correct.
--
-- The shader's alpha discard cuts the mon's exact outline out of the quad,
-- so a Pokemon is its own silhouette against the world with no matte, no
-- billboard edge and no sorting to get wrong.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local BattleBillboard = {}
-- How wide a full-size (7x7 tile) mon stands, in world pixels. One overworld
-- square, so a Pokemon covers the tile it is on and no more; a species whose
-- pic is smaller than the full buffer comes out proportionally smaller,
-- which is how the artwork's own size differences survive the trip.
BattleBillboard.FULL_W = 16
BattleBillboard.FULL_PIC = 56 -- the pic size FULL_W refers to
-- A hair of camera-ward bias, so a card standing ON the ground plane wins
-- the depth test against it instead of z-fighting the tile it is rooted to.
BattleBillboard.PULL = 1.5
local quad = nil -- nil = untried, false = unavailable
-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole
-- texture. Feet on the model origin, so the model matrix only has to say
-- where the mon is standing and how big it is.
local function unitQuad()
if quad ~= nil then return quad or nil end
local verts = {
{ -0.5, 0, 0, 0, 1, 1 },
{ 0.5, 0, 0, 1, 1, 1 },
{ 0.5, 1, 0, 1, 0, 1 },
{ -0.5, 1, 0, 0, 0, 1 },
}
local indices = {}
Voxel3D.pushQuad(indices, 0)
local mesh = Voxel3D.newMesh(verts, indices)
quad = mesh or false
return quad or nil
end
BattleBillboard.mesh = unitQuad
-- The yaw that turns the card's face toward the eye. The quad's normal is
-- +Z before rotation, so this is just the bearing from the mon to the
-- camera -- flattened to the horizontal, because a card that also tipped to
-- face a camera above it would lift its feet off the floor.
function BattleBillboard.yawToward(x, z, eye)
if not eye then return 0 end
return math.atan2(eye[1] - x, eye[3] - z)
end
-- Stand a `w` x `h` card with its feet centred on world (x, y, z).
function BattleBillboard.matrix(x, y, z, w, h, yaw)
return Mat4.mul(Mat4.mul(Mat4.translate(x, y, z), Mat4.rotateY(yaw)),
Mat4.scale(w, h, 1))
end
-- The world size a pic of `pw` x `ph` texture pixels stands at.
function BattleBillboard.sizeFor(pw, ph)
if not (pw and ph and pw > 0 and ph > 0) then return 0, 0 end
local scale = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
return pw * scale, ph * scale
end
-- Draw one mon. `tex` is the pic already rendered to a texture (see
-- OverworldBattle, which lets the engine's own battler draw produce it, so
-- every faint slide, blink and squish comes along), `grow` the send-out
-- animation's scale or nil.
function BattleBillboard.draw(tex, x, y, z, grow)
local mesh = unitQuad()
if not (mesh and tex) then return false end
local pw, ph = tex:getDimensions()
local w, h = BattleBillboard.sizeFor(pw, ph)
if grow then w, h = w * grow, h * grow end
if w <= 0 or h <= 0 then return false end
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
BattleBillboard.PULL)
return true
end
-- The same card, as the SUN sees it: no camera-ward pull (that is a trick
-- for the view's own depth buffer and would drag the shadow off its owner)
-- and no draw call of its own, because the shadow pass has its own.
function BattleBillboard.caster(shadowMap, tex, x, y, z, grow)
local mesh = unitQuad()
if not (mesh and tex) then return false end
local pw, ph = tex:getDimensions()
local w, h = BattleBillboard.sizeFor(pw, ph)
if grow then w, h = w * grow, h * grow end
if w <= 0 or h <= 0 then return false end
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
shadowMap.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw))
return true
end
function BattleBillboard.invalidate()
quad = nil
end
return BattleBillboard
+193
View File
@@ -0,0 +1,193 @@
-- Overworld battles: the over-the-shoulder camera and its parallax drift.
--
-- The two mons are PINNED to their cells: each pic is drawn wherever its
-- patch of ground projects to, not at a fixed screen slot. So the camera is
-- not decoration -- it is the thing that decides where the fight appears,
-- and it has to put those two patches of ground exactly where the battle
-- screen wants its two pics:
--
-- the player's mon (26, 96) back pic, feet on the text box, well left
-- the enemy's mon (124, 56) front pic, bottom of the 7x7 slot
--
-- Four screen coordinates, so four equations. The rig below is the solution:
-- SIDE / BACK / HEIGHT place the eye relative to the arena's midpoint, LOOK
-- aims it, and FRAME_H sets the lens, and together they land both marks
-- within a thousandth of a pixel of the targets. They are not hand-picked
-- numbers that looked about right -- they came out of a solver, and the
-- suite reprojects them so a future edit either still lands or says so.
--
-- East is what decides which mon is on which side. The arena axis runs north
-- (the enemy) to south (the player's mon), and a camera east of that axis
-- sees the near end swing LEFT and the far end RIGHT -- the layout arrived
-- at by standing in the right place rather than by mirroring anything.
--
-- ------- and two more equations, from the pixels
--
-- The pics are pixel art and their size on screen is not something the mod
-- gets to choose: 56 pixels for a front pic, 64 for a back one. And a mon has
-- to stand in ONE OVERWORLD SQUARE, or it towers over the houses and gives
-- away that the world behind it is a picture. Together those say the square
-- each mon stands on must project to about the width of its own pic, which is
-- two more equations for the same six unknowns -- and they are what set the
-- distance.
--
-- The answer is a LONG LENS FROM A LOW STANCE: twelve degrees above the
-- floor, twelve degrees wide, from five blocks back. Not a stylistic choice
-- -- it is what a 56-pixel sprite standing on a 16-pixel tile forces on a
-- 160-pixel screen. Roughly three tiles fit across the frame, so the camera
-- has to be far away and zoomed in rather than near and wide. That is the
-- DEFAULT rig, and every map that can take it gets it.
--
-- ------- the exception: rooms too small to stand back from
--
-- Five blocks back is further than some rooms are wide. A gym is about ten
-- cells across, so on one the eye lands OUTSIDE the map, where the border
-- ring the engine draws round every map -- extruded into a cliff by this mode
-- -- crosses the near Pokemon wherever it stands. Three gyms could not be
-- staged anywhere at all for that reason.
--
-- So there is a second rig, and an arena asks for it by name (cam = "wide"
-- in data/battle_arenas.lua). It comes in to about four cells with the lens
-- opened up to match: an ordinary 44-degree shot that fits inside the room.
-- The mons render smaller for it -- a bit over half a tile rather than a
-- whole one -- which is the price. Both rigs are solved against the SAME four
-- anchors, so the composition is identical either way; only the lens and the
-- distance differ, which is what makes it safe to pick per map.
--
-- Rooms too small for the long lens are the reason it exists, but it is not
-- only for them: an area that simply reads better with more of itself in
-- shot can ask for it too.
--
-- Purely presentational, like everything else in this mod: the camera looks
-- at the map, and nothing it does reaches collision, movement or scripts.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleCam = {}
-- ------- the rig, in world pixels (a map cell is 16, a block 32)
--
-- Solved against the four anchors and two spans above, with the two mons 48
-- world pixels (three cells) apart -- BattleArena.SHAPES is where that gap is
-- set, and changing it invalidates these.
-- `frameH` is how much world the frame is tall enough to hold at the aim
-- distance, which together with that distance is the lens.
-- Named for the LENS, because that is what an author is choosing between
-- when they look at a shot and decide it wants more room in it.
BattleCam.RIGS = {
-- the default: a long 11.5-degree lens from five blocks back, which is
-- what makes one tile big enough to stand a 56-pixel mon on
tele = {
side = 78.79, back = 144.96, height = 37.88,
lookX = -0.26, lookY = 0.34, frameH = 34.11,
},
-- 44 degrees from four cells: fits inside a room the long lens cannot
-- stand back from, and shows more of anywhere else, at the cost of a
-- smaller pair
wide = {
side = 41.98, back = 41.16, height = 28.48,
lookX = -3.24, lookY = -1.35, frameH = 55.62,
},
}
BattleCam.DEFAULT_RIG = "tele"
-- The rig an arena asks for, falling back to the default for anything that
-- does not ask (and for a name that is not one of the two).
function BattleCam.rigFor(arena)
local want = arena and arena.cam
return BattleCam.RIGS[want] or BattleCam.RIGS[BattleCam.DEFAULT_RIG]
end
-- ------- the drift
--
-- A slow orbit about the arena's vertical axis. Rotating about a point
-- BETWEEN the two mons is what makes it parallax rather than a pan: the mons
-- are pinned to the ground, so the near one slides one way across the frame
-- and the far one slides the OTHER, by the amount their difference in
-- distance implies. Over a full swing that is about eight pixels of relative
-- movement -- plainly visible as depth, far too slow to fight the fight.
-- The angle is small because the lens is long: two degrees of orbit is seven
-- pixels of travel through an eleven-degree field of view.
--
-- Under it, a much smaller breath in and out along the same line, on an
-- unrelated period, so the pair never returns to the same pose on any cycle
-- a battle is long enough to show. A DOLLY rather than a pan of the aim:
-- moving the aim point would slide both mons the same way, which with pinned
-- pics is just the whole picture walking sideways. Changing the DISTANCE
-- moves them apart and back together about the frame's centre, which is the
-- same depth cue the orbit gives, from the other axis.
BattleCam.PAN_YAW = math.rad(2) -- half-angle of the orbit
BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
BattleCam.DOLLY_PERIOD = 37
BattleCam.t = 0
function BattleCam.reset()
BattleCam.t = 0
end
-- Real frame time, like every other presentational tween in this mod: a
-- fast-forwarded battle must not spin the camera.
function BattleCam.update(dt)
BattleCam.t = BattleCam.t + (dt or 0)
-- keep the phase small forever rather than letting a long session lose
-- float precision in the sines below
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
end
local function phase(t, period)
return math.sin(2 * math.pi * t / period)
end
-- The camera for `arena` this instant: the record Voxel3D.camera takes, plus
-- the pitch the pull and the sun frustum want (measured from straight down,
-- the same convention Voxel.angle uses).
--
-- `fov` here frames the GB's 160x144. A caller rendering at window
-- resolution widens it for the extra picture around that frame -- see
-- BattleScene.letterboxFov, which is what keeps the pins exact at any window
-- size.
--
-- `groundY` is the height of the arena floor, so a fight staged on a ledge
-- or a raised walkway is shot from above THAT rather than from inside it.
function BattleCam.rig(arena, groundY)
groundY = groundY or 0
local R = BattleCam.rigFor(arena)
local mx, mz = arena.mid[1], arena.mid[2]
local yaw = 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
-- along its own line to the arena and the pitch of the shot never changes
local k = 1 + BattleCam.PAN_DOLLY
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
local dx = (R.side * c - R.back * s) * k
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 }
local ex = eye[1] - focus[1]
local ey = eye[2] - focus[2]
local ez = eye[3] - focus[3]
local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
local horiz = math.sqrt(ex * ex + ez * ez)
return {
eye = eye,
focus = focus,
fov = 2 * math.atan((R.frameH / 2) / dist),
-- the world curve is a free-roam flourish that bends the horizon away
-- from the player; a fixed camera on a staged shot has no player to bend
-- around, and the bend would tip the arena floor out from under the mons
-- the pics are pinned to
curve = 0,
}, math.atan2(horiz, math.max(1e-3, ey))
end
return BattleCam
+187
View File
@@ -0,0 +1,187 @@
-- Overworld battles: the depth-of-field pass over the arena.
--
-- A photographic lens focused on two subjects three cells apart holds a
-- narrow slab of the world sharp and loses everything in front of and
-- behind it. On this shot that slab is a BAND ACROSS THE FRAME, because the
-- camera is fixed and looking down at a floor: distance from the lens runs
-- monotonically up the picture, so screen height IS depth, and a band of
-- rows is a slab of world. The floor the mons stand on stays sharp, the
-- middle distance and the horizon soften, and the foreground the camera is
-- leaning over softens the other way.
--
-- BOTH MONS ARE IN FOCUS, and not by tuning: they are drawn as the battle
-- screen's own pics AFTER this runs, so they are never blurred at all. This
-- pass only ever touches the world behind them -- which is exactly the
-- separation a real shot of two subjects at the same distance would have.
--
-- Two separable gaussian passes over a 160x144 image: two full-frame draws
-- of 23,040 pixels, which is nothing, and it buys the one cue that stops a
-- 3D backdrop reading as wallpaper.
local BattleDOF = {}
-- Switched off for now. The pass is kept whole -- band maths, shader,
-- canvases -- because the reason to have it has not gone away: it is the one
-- cue that separates the pair from the ground behind them. It is off because
-- the mons are geometry in the scene now rather than pics over it, and a blur
-- that softens their own tile softens THEM, which the pinned-pic version
-- never had to answer for. Turning it back on means solving that first.
BattleDOF.ENABLED = false
-- Fallbacks for the band, in canvas uv. The caller normally measures it off
-- the two ground marks -- the whole point is that the slab in focus is the
-- one the mons are standing in -- and these are what a caller that cannot
-- gets instead.
BattleDOF.FOCUS_Y = 0.52
BattleDOF.BAND = 0.16
BattleDOF.RANGE = 0.32
-- How much floor either side of the two marks stays sharp, as a fraction of
-- the gap between them: a mon is taller than the patch it stands on, and the
-- ground just in front of and behind it belongs to the same slab.
BattleDOF.BAND_MARGIN = 0.55
-- How far past the band it takes to reach full blur, in the same units as
-- the band itself.
BattleDOF.RANGE_SCALE = 2.0
-- Tap spacing at full blur, as a fraction of the canvas height, so the blur
-- is the same depth of field in a window and fullscreen. The gaussian's
-- reach is four taps and the two passes compound, so a little goes a long
-- way.
BattleDOF.SPACING = 0.0095
-- A touch of saturation on the way out, the same trick the tilt-shift pass
-- uses: a blurred background reads as further away when it is also a
-- little richer than the sharp subject in front of it.
BattleDOF.SATURATION = 1.12
local SHADER = [[
uniform vec2 dir; // one texel step along the axis being blurred
uniform float focusY;
uniform float band;
uniform float range;
uniform float spacing;
uniform float boost; // 0 = plain blur pass, 1 = final pass (colour pop)
uniform float saturation;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float d = abs(tc.y - focusY) - band;
float s = clamp(d / range, 0.0, 1.0);
s = s * s; // ease in, so the band edge has no visible seam
vec2 o = dir * (s * spacing);
vec4 sum = Texel(tex, tc) * 0.2270270270;
sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946;
sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216;
sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541;
sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162;
if (boost > 0.5) {
float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114));
sum.rgb = mix(vec3(luma), sum.rgb, saturation);
}
return sum * color;
}
]]
local shader = nil -- nil = untried, false = unavailable
local ping, pong, cw, ch = nil, nil, 0, 0
local function getShader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER)
shader = (ok and sh) or false
end
return shader or nil
end
-- Its own pair of canvases rather than the tilt-shift pass's: those are
-- sized to the window and this is sized to the GB frame, and sharing them
-- would reallocate both every time a battle started or ended.
local function getCanvases(w, h)
if not ping or cw ~= w or ch ~= h then
local ok, a = pcall(love.graphics.newCanvas, w, h)
if not ok then return nil end
local okB, b = pcall(love.graphics.newCanvas, w, h)
if not okB then return nil end
-- the gaussian's fractional tap offsets need linear filtering
a:setFilter("linear", "linear")
b:setFilter("linear", "linear")
ping, pong, cw, ch = a, b, w, h
end
return ping, pong
end
-- The sharp band for a shot whose two ground marks land at canvas rows
-- `y1` and `y2`, as (focusY, band, range) in uv. This is the depth of field
-- proper: the band is the slab of world the two mons occupy, and everything
-- nearer or further softens away from it.
function BattleDOF.bandFor(y1, y2, h)
if not (y1 and y2 and h and h > 0) then
return BattleDOF.FOCUS_Y, BattleDOF.BAND, BattleDOF.RANGE
end
local mid = (y1 + y2) / 2 / h
local half = math.abs(y1 - y2) / 2 / h
local band = half * (1 + BattleDOF.BAND_MARGIN)
return math.min(1, math.max(0, mid)),
band,
math.max(1e-3, band * BattleDOF.RANGE_SCALE)
end
-- Run the pass over `canvas` and return the result, or the input unchanged
-- when it cannot run (headless, no shader support) -- so the caller always
-- has something to composite. `focusY`, `band` and `range` are in canvas uv;
-- omit them for the fixed fallback band.
function BattleDOF.apply(canvas, focusY, band, range)
if not (canvas and BattleDOF.ENABLED) then return canvas end
local sh = getShader()
if not sh then return canvas end
local w, h = canvas:getDimensions()
local a, b = getCanvases(w, h)
if not a then return canvas end
focusY = focusY or BattleDOF.FOCUS_Y
band = band or BattleDOF.BAND
range = range or BattleDOF.RANGE
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local prevCanvas = love.graphics.getCanvas()
-- the scene canvas filters nearest for its 1:1 blit; the taps need linear,
-- restored below so the composite sees what it expects
canvas:setFilter("linear", "linear")
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
-- replace, not alpha-blend: these are image-processing copies
love.graphics.setBlendMode("replace", "premultiplied")
pcall(sh.send, sh, "focusY", focusY)
pcall(sh.send, sh, "band", band)
pcall(sh.send, sh, "range", range)
pcall(sh.send, sh, "spacing", math.max(0.75, h * BattleDOF.SPACING))
pcall(sh.send, sh, "saturation", BattleDOF.SATURATION)
local ok = pcall(function()
love.graphics.setCanvas(a)
pcall(sh.send, sh, "dir", { 1 / w, 0 })
pcall(sh.send, sh, "boost", 0)
love.graphics.draw(canvas)
love.graphics.setCanvas(b)
pcall(sh.send, sh, "dir", { 0, 1 / h })
pcall(sh.send, sh, "boost", 1)
love.graphics.draw(a)
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setShader()
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
canvas:setFilter("nearest", "nearest")
return ok and b or canvas
end
-- Drop the GPU objects (window resize, hot reload).
function BattleDOF.invalidate()
ping, pong, cw, ch = nil, nil, 0, 0
end
return BattleDOF
+349
View File
@@ -0,0 +1,349 @@
-- Overworld battles: the HUD's footing on a world that is not white.
--
-- Gen 1 draws its battle HUDs as black glyphs and bar tiles straight onto
-- the white field, with no box around them -- the field IS the backing. Take
-- the field away and put a route underneath and the name, the level and the
-- HP numbers are black on grass, which is not readable.
--
-- So each HUD block gets a panel: the world behind it, blurred to frosted
-- glass and laid back down translucent, with a tint that pushes it away from
-- whatever colour the text is about to be. Frosted rather than opaque
-- because the point of the mode is that you can see where you are standing,
-- and an opaque slab in the corner of the frame is the white field back
-- again by another name.
--
-- And the text flips. A panel over a sunlit meadow is bright and wants black
-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
-- white ones. So the panel's average brightness is measured and the glyphs
-- follow it, with hysteresis so a slow camera drift across the threshold
-- cannot strobe them.
--
-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
-- a few times a second rather than every frame. The camera drifts at about
-- a pixel a second; brightness cannot outrun that.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleHud = {}
-- How solid the frost is over the world behind it, and how far the tint
-- pushes it toward the far end from the text.
--
-- Both deliberately light. The panel is there to make glyphs legible, not to
-- put a slab in the corner of the frame: at these values the sharp world
-- still reads through it and the blur registers as a pane of glass rather
-- than as a second background.
BattleHud.FROST = 0.55
BattleHud.TINT = 0.26
-- The luminance the glyphs flip at, with a dead band so a drift across it
-- settles rather than strobes.
BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs
BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones
-- Frames between brightness readbacks.
BattleHud.SAMPLE_EVERY = 12
-- The frost buffer's height; width follows the source's aspect. Small on
-- purpose: the downscale is most of the blur, and what is read back for the
-- brightness is one pixel of it.
BattleHud.FROST_H = 72
local frost, frostW, frostH = nil, 0, 0
local blurA, blurB = nil, nil
local probe = nil
local frame = 0
local luma = {} -- panel key -> { value, dark, at }
local SHADER = [[
uniform vec2 dir;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 sum = Texel(tex, tc) * 0.2270270270;
sum += (Texel(tex, tc + dir) + Texel(tex, tc - dir)) * 0.1945945946;
sum += (Texel(tex, tc + 2.0 * dir) + Texel(tex, tc - 2.0 * dir)) * 0.1216216216;
sum += (Texel(tex, tc + 3.0 * dir) + Texel(tex, tc - 3.0 * dir)) * 0.0540540541;
sum += (Texel(tex, tc + 4.0 * dir) + Texel(tex, tc - 4.0 * dir)) * 0.0162162162;
return sum * color;
}
]]
local shader = nil -- nil = untried, false = unavailable
local function getShader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER)
shader = (ok and sh) or false
end
return shader or nil
end
local function canvasOf(w, h, filter)
local ok, c = pcall(love.graphics.newCanvas, w, h)
if not ok then return nil end
c:setFilter(filter or "linear", filter or "linear")
return c
end
-- Build (or rebuild) the frosted copy of `src` for this frame.
--
-- Two steps, because one is not enough: the downscale to a 72-row buffer
-- averages the world down to something that no longer reads as terrain, and
-- the separable gaussian over that turns the remaining structure into
-- frosted glass rather than a mosaic of the tiles it came from.
function BattleHud.build(src)
if not src then return nil end
local blur = getShader()
local sw, sh = src:getDimensions()
if sw <= 0 or sh <= 0 then return nil end
local h = BattleHud.FROST_H
local w = math.max(1, math.floor(sw * h / sh + 0.5))
if not frost or frostW ~= w or frostH ~= h then
frost = canvasOf(w, h)
blurA = canvasOf(w, h)
blurB = canvasOf(w, h)
probe = probe or canvasOf(1, 1)
if not (frost and blurA and blurB) then
frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
return nil
end
frostW, frostH = w, h
end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local prevFilter = { src:getFilter() }
src:setFilter("linear", "linear")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("replace", "premultiplied")
local ok = pcall(function()
love.graphics.setCanvas(frost)
love.graphics.draw(src, 0, 0, 0, w / sw, h / sh)
if blur then
love.graphics.setShader(blur)
love.graphics.setCanvas(blurA)
pcall(blur.send, blur, "dir", { 2.5 / w, 0 })
love.graphics.draw(frost)
love.graphics.setCanvas(blurB)
pcall(blur.send, blur, "dir", { 0, 2.5 / h })
love.graphics.draw(blurA)
love.graphics.setShader()
frost, blurB = blurB, frost -- the blurred one is the frost now
end
end)
love.graphics.setShader()
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
src:setFilter(prevFilter[1] or "nearest", prevFilter[2] or "nearest")
frame = frame + 1
return ok and frost or nil
end
function BattleHud.frame()
return frame
end
-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
--
-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
-- canvas, which IS the mean, and that one pixel is read back. Cached for
-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
-- nothing it measures moves faster than that.
local function sampleLuma(key, fx, fy, fw, fh)
local hit = luma[key]
if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
if not (frost and probe and frostW > 0) then return hit and hit.value end
if fw <= 0 or fh <= 0 then return hit and hit.value end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local value = hit and hit.value or 1
local ok = pcall(function()
love.graphics.setCanvas(probe)
love.graphics.setBlendMode("replace", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
love.graphics.setCanvas()
local data = probe:newImageData()
local r, g, b = data:getPixel(0, 0)
if data.release then pcall(data.release, data) end
value = 0.299 * r + 0.587 * g + 0.114 * b
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then return hit and hit.value end
luma[key] = { value = value, at = frame }
return value
end
-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
-- in the source the frost was built from.
local function frostRect(rect, box)
local kx = frostW / box.pw
local ky = frostH / box.ph
local fx = (box.lx + rect[1] * box.scale) * kx
local fy = (box.ly + rect[2] * box.scale) * ky
local fw = rect[3] * box.scale * kx
local fh = rect[4] * box.scale * ky
return fx, fy, math.max(1, fw), math.max(1, fh)
end
-- ------- the verdict
--
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
-- single pass and there is only one glyph colour to be had out of it -- and
-- a frame with a black-lettered HUD in one corner and a white-lettered one
-- in the other would read as a bug rather than as adaptation. The DARKER
-- panel decides, because it is the one that cannot afford to be wrong, and
-- the tint below then commits both panels to that reading.
local wasDark = false
function BattleHud.verdict(rects, box)
if not (frost and box and box.scale and box.scale > 0) then return false end
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = frostRect(rect, box)
local v = sampleLuma(key, fx, fy, fw, fh)
if v and (not darkest or v < darkest) then darkest = v end
end
if not darkest then return wasDark end
-- hysteresis: it takes a clear move past the far threshold to flip back,
-- so a camera drifting across the boundary settles instead of strobing
if wasDark then
wasDark = darkest < BattleHud.DARK_LEAVE
else
wasDark = darkest < BattleHud.DARK_ENTER
end
return wasDark
end
-- Draw one HUD panel into the current target, in GB coordinates.
--
-- The tint always pushes AWAY from the glyph colour that is about to be
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
-- gets darker under white text, a bright one brighter under black text.
function BattleHud.panel(rect, box, dark)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = frostRect(rect, box)
local ok = pcall(function()
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
love.graphics.draw(frost, quad, rect[1], rect[2], 0,
rect[3] / fw, rect[4] / fh)
local shade = dark and 0 or 1
love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
love.graphics.setColor(1, 1, 1, 1)
end)
return ok
end
-- ------- flipping the glyphs
--
-- Over a dark panel the HUD's black text has to go white, and it cannot be
-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
-- times white is still black. The colour channel has to be REPLACED.
--
-- So the HUD is drawn into a scratch layer and that layer is composited back
-- through a shader that whitens whatever is nearly black and leaves the rest
-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
-- everything the HUD draws as ink -- while the HP bar's own greens and reds
-- are well clear of the threshold and come through untouched.
--
-- Composited back into whatever the caller had bound, which is what makes it
-- work in both pipelines without knowing which one it is in: in the colorized
-- one that target is the grayscale BG canvas, where white IS shade 0 and the
-- zone pass then colours the flipped glyphs like every other lightest-shade
-- surface; in the flat fallback it is the screen, where white is white.
local INK = 0.35 -- luminance at or under which a pixel counts as ink
local FLIP = [[
uniform float ink;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
return p * color;
}
]]
local flipShader = nil
local layer = nil
local function getFlip()
if flipShader == nil then
local ok, sh = pcall(love.graphics.newShader, FLIP)
flipShader = (ok and sh) or false
end
return flipShader or nil
end
-- Whether the flip pass can run at all, for the shot driver's log.
function BattleHud.flipReady()
return getFlip() ~= nil
end
-- Run `fn` with its ink whitened. Falls back to running it plainly when the
-- scratch layer or the shader is unavailable, so a driver that cannot do
-- either gets the vanilla black HUD rather than no HUD.
function BattleHud.flipGlyphs(w, h, fn)
local sh = getFlip()
if not sh then return fn() end
if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
layer = canvasOf(w, h, "nearest")
if not layer then return fn() end
end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local ok, err = pcall(function()
love.graphics.setCanvas(layer)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("alpha")
fn()
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then error(err, 0) end
love.graphics.setShader(sh)
pcall(sh.send, sh, "ink", INK)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(layer, 0, 0)
love.graphics.setShader()
end
-- The last luminance measured, for the shot driver's log.
function BattleHud.lastLuma()
local best = nil
for _, hit in pairs(luma) do
if not best or hit.value < best then best = hit.value end
end
return best
end
function BattleHud.invalidate()
frost, blurA, blurB, probe = nil, nil, nil, nil
frostW, frostH = 0, 0
luma = {}
wasDark = false
end
return BattleHud
+157
View File
@@ -0,0 +1,157 @@
-- Overworld battles: giving a battle pic its paper back.
--
-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
-- engine's decoded PNGs key that shade to alpha 0 -- which was free, because
-- the field behind them was white too. A transparent belly on a white page
-- is a white belly.
--
-- Put a route behind it and the belly is grass. Charizard's chest, the whites
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
-- hole with the world showing through, and the mon reads as a stencil.
--
-- So the paper is put back, and only where the paper was: the pic is read
-- back once, the transparent region OUTSIDE the figure is flood-filled from
-- the border, and every transparent pixel the flood could not reach -- every
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
-- untouched, so the mon still cuts cleanly against the world; only its
-- insides stop being see-through.
--
-- Read back off the GPU rather than off the asset, deliberately. What comes
-- back is the pic the engine actually decided to draw -- species palette,
-- forced-mono rebuild, shiny recolour, a mod's replacement art -- so this
-- needs to know nothing about how any of that was arrived at. Once per pic
-- per session, cached on the image itself.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattlePics = {}
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
-- out of scope takes its filled twin with it rather than pinning a texture
-- for the session.
local cache = setmetatable({}, { __mode = "k" })
-- What an enclosed hole is filled with. White, because white is what the
-- battle field was: this restores the pixel the artist drew and the engine
-- then keyed away, it does not invent a new one.
BattlePics.FILL = { 1, 1, 1, 1 }
-- Anything at or under this alpha counts as keyed-out rather than drawn.
local CUT = 0.5
-- Read the pixels the engine would actually blit. A LOVE Image does not hand
-- its data back, so it is drawn into a canvas of its own size and the canvas
-- is read -- which is also what makes this work for every path that produces
-- a pic, without knowing which one produced this one.
local function readBack(img)
local w, h = img:getDimensions()
if w <= 0 or h <= 0 then return nil end
local prevCanvas = love.graphics.getCanvas()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local data = nil
local ok = pcall(function()
local canvas = love.graphics.newCanvas(w, h)
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("replace", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 0, 0)
love.graphics.setCanvas()
data = canvas:newImageData()
if canvas.release then pcall(canvas.release, canvas) end
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
love.graphics.setColor(prevR or 1, prevG or 1, prevB or 1, prevA or 1)
return ok and data or nil
end
-- Mark every transparent pixel reachable from the border. That set is the
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
--
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
-- pixels and a keyed-out background is most of them, which is a deeper call
-- chain than is worth risking for no gain.
local function markOutside(data, w, h)
local outside = {}
local stack, top = {}, 0
local function push(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return end
local key = y * w + x
if outside[key] then return end
local _, _, _, a = data:getPixel(x, y)
if a > CUT then return end
outside[key] = true
top = top + 1
stack[top] = key
end
for x = 0, w - 1 do
push(x, 0)
push(x, h - 1)
end
for y = 0, h - 1 do
push(0, y)
push(w - 1, y)
end
while top > 0 do
local key = stack[top]
top = top - 1
local x, y = key % w, math.floor(key / w)
push(x - 1, y)
push(x + 1, y)
push(x, y - 1)
push(x, y + 1)
end
return outside
end
-- The pic with its enclosed holes filled, or the pic itself when that could
-- not be done (no pixel access, a driver that refused the readback). Never
-- nil for a non-nil argument: a caller must always have something to draw.
function BattlePics.filled(img)
if not img then return img end
local hit = cache[img]
if hit ~= nil then return hit or img end
local made = nil
local ok = pcall(function()
local data = readBack(img)
if not data then return end
local w, h = data:getDimensions()
local outside = markOutside(data, w, h)
local fill = BattlePics.FILL
local changed = false
for y = 0, h - 1 do
local row = y * w
for x = 0, w - 1 do
if not outside[row + x] then
local _, _, _, a = data:getPixel(x, y)
if a <= CUT then
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
changed = true
end
end
end
end
-- nothing enclosed: hand the original back rather than a copy of it
if not changed then return end
local out = love.graphics.newImage(data)
out:setFilter("nearest", "nearest")
made = out
end)
cache[img] = (ok and made) or false
return made or img
end
function BattlePics.invalidate()
cache = setmetatable({}, { __mode = "k" })
end
return BattlePics
+451
View File
@@ -0,0 +1,451 @@
-- Overworld battles: one frame of the arena, as geometry.
--
-- The same world the free-roam mode draws, from a placed camera instead of
-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
-- backdrop reaches the screen through Renderer's worldOverride, the seam a
-- render pipeline's finished world image already composites through, which
-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen
-- then blits over it in the classic letterbox. So the world is as crisp as
-- the free-roam diorama and the pics, HUDs and text box stay exactly the
-- chunky GB art they are.
--
-- Rendering the whole window rather than just the letterbox means the
-- framing has to be split in two. The RIG frames the GB's 160x144 (see
-- BattleCam, which is solved against coordinates in that frame); this
-- module widens the lens by exactly the ratio the window bears to the
-- letterbox, so the letterbox sub-rectangle of what gets rendered is
-- bit-for-bit the framing the rig asked for, and everything outside it is
-- extra picture. That is what lets the two mons be PINNED: their cells
-- project to the same GB coordinates at any window size or zoom.
--
-- Characters are deliberately absent. The overworld cast is culled for the
-- length of the battle (see OverworldBattle), so this pass has terrain,
-- grass and flowers and nothing that walks -- the arena is empty, which is
-- what makes it an arena.
--
-- Everything expensive is shared with the free-roam mode rather than
-- duplicated: the same chunk meshes out of ChunkMesher, the same palette
-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a
-- map already meshed for walking around costs the frame it draws and
-- nothing else.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
local ChunkMesher = V.require("ChunkMesher")
local TerrainAtlas = V.require("TerrainAtlas")
local VoxelScene = V.require("VoxelScene")
local BattleCam = V.require("BattleCam")
local BattleBillboard = V.require("BattleBillboard")
local VoxelGrid = V.require("VoxelGrid")
local PaletteFX = require("src.render.PaletteFX")
local BattleScene = {}
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against.
BattleScene.GB_W = 160
BattleScene.GB_H = 144
-- A map cell in world pixels: the overworld square a mon stands on, which is
-- both what the arena is measured in and what a mon is sized to.
BattleScene.CELL = 16
-- How far into black a shadow goes in the arena, against the free-roam
-- mode's own lighter setting.
--
-- Darker on purpose, and only here. Walking around, a shadow is scenery and
-- wants to stay out of the way of reading the map. In a battle it is doing
-- one specific job: the two mons are flat cards, and the ONLY thing telling
-- the eye they are standing on that floor rather than hanging in front of it
-- is the shadow they put on it. A faint one leaves them floating.
BattleScene.SHADOW_ALPHA = 0.68
-- Which rung of the sky ramp an indoor void is painted with. A room has no
-- sky, but it does have somewhere the geometry stops, and leaving that
-- transparent would show the letterbox clear through the gaps.
local INDOOR_SHADE = 4
-- ------- where the GB frame sits inside the window
--
-- Renderer blits worldOverride one canvas pixel to one screen pixel and then
-- blits the 160x144 UI canvas into a centred, integer-scaled letterbox. So
-- these have to agree with Renderer:endFrame exactly, or the pins land off
-- the mons by however much they disagree.
function BattleScene.letterbox()
local Renderer = require("src.render.Renderer")
local pw, ph = BattleScene.pixelSize()
local s = Renderer:fitScale()
return math.floor((pw - BattleScene.GB_W * s) / 2),
math.floor((ph - BattleScene.GB_H * s) / 2),
s, pw, ph
end
-- The window in FRAMEBUFFER pixels, which is what the override blit works
-- in. love.graphics.getDimensions is in LOVE units and differs from this by
-- the display density on mobile.
function BattleScene.pixelSize()
if love.graphics.getPixelDimensions then
local pw, ph = love.graphics.getPixelDimensions()
if pw and ph and pw > 0 and ph > 0 then return pw, ph end
end
return love.graphics.getDimensions()
end
-- Widen the rig's vertical field of view from the GB frame to the whole
-- window, so the letterbox rows show exactly what the rig framed.
--
-- The horizontal falls out of it: at aspect pw/ph the window's half-width is
-- tan(fov/2) * pw/ph, and the letterbox is 160*s of those pw pixels, which
-- works back out to the GB frame's own 160/144. So one scale on the vertical
-- pins both axes.
function BattleScene.letterboxFov(fovGB, ph, s)
local span = BattleScene.GB_H * s
if span <= 0 then return fovGB end
return 2 * math.atan(math.tan(fovGB / 2) * ph / span)
end
-- ------- palette
--
-- The world palette a map draws under, in the shape VoxelScene's colour
-- helpers take. Rebuilt per frame from the overworld state, which is where
-- the engine's own pipeline context gets it too (OverworldController's
-- ctx.paletteFor).
local function paletteFor(state, home)
return function(map)
return PaletteFX.pal(require("src.core.Game").data,
state:paletteNameFor(map or home))
end
end
-- ------- the map the fight is staged on
--
-- Normally the one the player is standing on. An authored arena may name
-- another floor of the same cave or building (see BattleArena), and then the
-- scene is THAT map: its terrain, its palette, its sky. Nothing else in the
-- battle changes -- the fight, the party, the player's own position are all
-- exactly where they were.
--
-- A foreign floor is meshed alone, with no connected neighbours: connections
-- are the player's neighbourhood, and the map the camera has gone to visit is
-- not standing in it. Both maps are kept live so neither the arena's mesh nor
-- the one waiting to be walked back onto is evicted mid-battle.
local function prefetchArena(state, host)
if host == state.map then return VoxelScene.prefetch(state) end
local live = { [host.id] = true, [state.map.id] = true }
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
ChunkMesher.setLive(live)
TerrainAtlas.setLive(live)
local terrain = ChunkMesher.request(host, false, nil, true)
or ChunkMesher.peek(host, true)
return terrain, {}
end
-- ------- the sun
--
-- Only has to be drawn once per battle: the arena does not move, and neither
-- does the light. So the signature is the map, the arena and the meshes --
-- not the camera, which is the one thing that IS moving and the one thing
-- the sun does not care about.
-- ------- the two mons, hung on their cells
--
-- The billboard texture is the battle screen's own 160x144 pics layer with
-- one side rendered into it (see OverworldBattle.sideTexture), so the quad is
-- that whole frame stood up on the map -- which is what carries every pic
-- effect the engine applies without any of them being reimplemented here.
--
-- Its size follows from one number: a full 7x7-tile mon covers one overworld
-- square, so a canvas pixel is FULL_W / FULL_PIC world pixels and the card is
-- the canvas at that scale. Its placement follows from the anchor the
-- texture reports -- the column the pic was centred on and the row its feet
-- were put on -- which is translated onto the cell before the card is stood
-- up, so a mon of any size in any pose has its feet on the ground.
-- `mirror` flips the card about its own anchor column. Both mons wear their
-- FRONT pic, which is drawn facing out of the screen -- so dropped into the
-- world unaltered the pair stand back to back, both looking the same way past
-- each other. Mirroring the near one turns it to face the far one, which is
-- what a fight looks like; and because it is a flip about the pic's own
-- centre the feet do not move off the tile.
--
-- The player's TRAINER pic is the exception, and it is exempted below. That
-- one is a BACK view -- the player seen from behind, already turned to face
-- up the field -- so it arrives pointing the right way and mirroring it would
-- turn it around to face the camera it is standing in front of.
local function monMatrix(tex, x, groundY, z, mirror)
local k = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
local w = BattleScene.GB_W * k
local h = BattleScene.GB_H * k
local ox = -((tex.ax / BattleScene.GB_W) - 0.5) * w
local oy = -((BattleScene.GB_H - tex.ay) / BattleScene.GB_H) * h
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1))
if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end
return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
card)
end
-- Every mon that has something to show this frame, as (texture, matrix).
local function monCards(arena, groundY, textures)
local out = {}
if not textures then return out end
for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy
if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer
out[#out + 1] = { tex = tex.canvas,
model = monMatrix(tex, cell[1], groundY, cell[2],
mirror) }
end
end
return out
end
BattleScene.monCards = monCards
-- The sun has to see the mons too, or they stand on the ground without
-- putting anything on it. They are the one thing in this scene that MOVES,
-- so `token` -- a counter the caller bumps whenever a pic could have changed
-- -- goes in the signature; the terrain half of the answer would otherwise
-- keep a stale pass alive and freeze the shadows in whatever pose they were
-- first drawn in.
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
tostring(terrain), tostring(token or 0) }
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
atlasFor, cards, token, host, neighbors)
if not ShadowMap.available() then return end
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
if not ShadowMap.stale(sig) then return end
if not ShadowMap.begin(cx, cy, vw, vh) then return end
ShadowMap.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
end
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil)
for _, nb in ipairs(neighbors) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- the mons themselves, as the same cards the camera will see. Their alpha
-- is the silhouette, so what lands on the ground is the shape of the
-- Pokemon rather than a blob standing in for one.
for _, card in ipairs(cards or {}) do
ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model)
end
ShadowMap.finish(sig)
end
-- The height of the arena floor: the ground the two mons stand on. Both
-- cells are open, so they are normally the same; take the player's, which is
-- the one nearer the camera and therefore the one a mismatch would show up
-- against.
function BattleScene.groundY(map, arena)
local ok, h = pcall(VoxelScene.groundAt, map,
arena.playerCell[1], arena.playerCell[2])
return (ok and h) or 0
end
-- Where a world point lands in GB frame coordinates under `vp`, or nil when
-- it is behind the camera. This is the function the pins are built on: it
-- takes the window-resolution clip position and divides the letterbox back
-- out of it, so the answer is in the same 160x144 space the battle screen
-- draws its pics in.
function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph)
local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4]
local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8]
local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16]
if cw <= 1e-6 then return nil end
-- viewProjection already flipped clip Y into LOVE's Y-down convention
local px = (cx / cw * 0.5 + 0.5) * pw
local py = (cy / cw * 0.5 + 0.5) * ph
return (px - lx) / s, (py - ly) / s
end
-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} },
-- the two marks in GB coordinates -- or nil when there is nothing to draw
-- yet (the terrain mesh is still building, the driver has no depth support).
-- nil is not a failure: the caller simply leaves the battle screen as the
-- engine drew it for that frame.
-- White, for the hit flash, and how far toward it the card goes.
--
-- The shader replaces the card's colour rather than multiplying it, so at
-- full strength this is the sprite turned into a solid white silhouette --
-- which is what the effect is on a flat GB screen and far too much on a
-- sprite standing in a lit world. Held well short of 1, the mon's own
-- shading still reads through the flash: it looks struck rather than
-- deleted.
BattleScene.FLASH_COLOR = { 1, 1, 1 }
BattleScene.FLASH_STRENGTH = 0.5
function BattleScene.render(state, arena, textures, token)
if not (state and state.map and arena) then return nil end
if not Voxel3D.available() then return nil end
-- the floor the fight is staged on: normally the player's own, sometimes
-- another floor of the same cave or building (see BattleArena)
local host = arena.map or state.map
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh = prefetchArena(state, host)
if not terrain then return nil end
local lx, ly, s, pw, ph = BattleScene.letterbox()
if not (pw > 0 and ph > 0 and s > 0) then return nil end
local palette = paletteFor(state, host)
local function atlasFor(map)
return TerrainAtlas.forMap(map, VoxelScene._modeColors(palette, map))
end
local groundY = BattleScene.groundY(host, arena)
local cam, pitch = BattleCam.rig(arena, groundY)
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
-- the world extents the sun frustum is fitted to; the camera itself is
-- framed by cam.fov, so these only have to describe the ground in shot
local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s)
local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live
-- before they are built; Voxel3D.eye is set by viewProjection, which
-- beginScene calls -- so a provisional one is taken here for the sun pass
-- and the real one is rebuilt inside the scene below.
Voxel3D.camera = cam
Voxel3D.viewProjection(cx, cy, vw, vh)
local cards = monCards(arena, groundY, textures)
Voxel3D.camera = nil
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
cards, token, host, neighbors)
-- An opaque void either way. Outdoors the camera is low enough that the
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
-- of the same ramp, which is a room's "past the wall". Transparent -- the
-- free-roam default -- would let the letterbox clear through wherever the
-- geometry stops.
local sky = VoxelScene.skyColor(host, 1)
or VoxelScene.skyShade(INDOOR_SHADE, 1)
Voxel3D.camera = cam
-- the sun is turned up for the arena and put back afterwards, so the
-- free-roam world it shares this module with keeps its own weight
local sunWas = Voxel3D.SHADOW_ALPHA
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
-- arena is a staged shot rather than the world being walked through, and
-- the seams are what make it read as built rather than photographed. Forced
-- through the override so the player's own row is never written to.
local gridWas = VoxelGrid.override
VoxelGrid.override = true
local out = nil
local ok, err = pcall(function()
-- its own canvas slot: this renders at the window's pixel size and the
-- free-roam pass does too, but the two are alive at different moments
-- and a shared slot would reallocate on every battle entry and exit
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
return
end
Voxel3D.draw(terrain, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- The mons, standing on their tiles. Depth-tested like everything else,
-- so a ledge or a tree between the camera and a Pokemon really is in
-- front of it, and the alpha discard cuts the sprite's own outline out of
-- the card. A small camera-ward pull keeps a card rooted to the ground
-- plane from z-fighting the tile it is standing on.
-- The engine's hit flash is a full-screen white rectangle, which on a
-- white battle field is a flash and over a world is a whiteout of the
-- map, the HUD and the text box alike. It is dropped on the way past
-- (see OverworldBattle) and put back HERE, on the two things it was ever
-- about: the mons themselves go solid white for those frames.
local flashing = textures and textures.flash
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
for _, card in ipairs(monCards(arena, groundY, textures)) do
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
-- orbit's -- there is no character here for them to overdraw, but the
-- pull is also what keeps a tuft from z-fighting the floor it stands on
local pull = VoxelScene.pull(math.max(pitch, 0.05))
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull)
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
end
local canvas = Voxel3D.endScene()
if not canvas then return end
local vp = Voxel3D.vp
local pmx, pmy = BattleScene.toGB(vp, arena.player[1], groundY,
arena.player[2], lx, ly, s, pw, ph)
local emx, emy = BattleScene.toGB(vp, arena.enemy[1], groundY,
arena.enemy[2], lx, ly, s, pw, ph)
if not (pmx and emx) then return end
-- How wide one overworld square is on screen where each mon stands, in
-- GB pixels. This is what the pics are scaled to: a mon covers its own
-- square and no more, at whatever the drift has done to the distance.
local half = BattleScene.CELL / 2
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
if not (pl and pr and el and er) then return end
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
playerSpan = math.abs(pr - pl),
enemySpan = math.abs(er - el),
-- the letterbox, so the depth-of-field pass can put its sharp band on
-- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
}
end)
-- the placed camera is ours for exactly this pass; anything else that
-- renders (the free-roam pipeline, next frame) must find the orbit back
Voxel3D.camera = nil
Voxel3D.SHADOW_ALPHA = sunWas
VoxelGrid.override = gridWas
if not ok then
-- endScene never ran, so the canvas is still bound and the shader still
-- set; put the frame back the way it was found before rethrowing
pcall(love.graphics.setShader)
pcall(love.graphics.setDepthMode)
pcall(love.graphics.setCanvas)
error(err, 0)
end
return out
end
return BattleScene
+747
View File
@@ -0,0 +1,747 @@
-- Overworld battles: fights that happen on the map you were standing on.
--
-- The engine's battle is a screen: a white field with two pics on it, pushed
-- over a frozen overworld that stops drawing. This turns that white field
-- into the world -- the same terrain the free-roam mode extrudes, shot from
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
-- animation, faint slide and text box is the engine's own, drawn in the
-- engine's own order. What changes is what is behind them, and where the two
-- pics stand.
--
-- The sequence, from the moment something picks a fight:
--
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
-- plays over an empty map and no bystander is left standing in the
-- arena shot
-- 2. the engine's own transition wipes the screen (untouched: it is the
-- right wipe, picked by the right three bits)
-- 3. the battle draws over a live, window-resolution render of the arena,
-- with each mon PINNED to the cell it is standing on, the camera
-- drifting slowly enough to read as parallax, and a depth-of-field pass
-- holding the slab of world the two of them occupy sharp
-- 4. the battle ends, the cast comes back, and the player is exactly
-- where they were standing
--
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
-- real warp would have to survive trainer sight-lines, post-battle
-- dialogue, the blackout path and every script that assumes the player is
-- where it left them -- and it would have to put them back afterwards.
-- Moving the camera buys the whole shot and owes nothing back.
--
-- The feature declines cleanly rather than half-working: no depth support,
-- no open ground on the map, the row switched off, or a mesh still building
-- all end at the same place, which is the battle screen the engine has
-- always drawn.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local BattleArena = V.require("BattleArena")
local BattleCam = V.require("BattleCam")
local BattleScene = V.require("BattleScene")
local BattleDOF = V.require("BattleDOF")
local BattleHud = V.require("BattleHud")
local BattlePics = V.require("BattlePics")
local Voxel3D = V.require("Voxel3D")
local ChunkMesher = V.require("ChunkMesher")
local OverworldBattle = {}
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
-- second, which is how the glyph flip is checked from a shot run. Read
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
-- diagnostic must never be the reason the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end
OverworldBattle.KEY = "battles"
OverworldBattle.LABEL = "3D-BTL"
-- On by default: a mod whose headline is "the world in 3D" should not need
-- the player to go and find the switch before the world shows up in a
-- battle. ON is first, so it is also what an unreadable stored value falls
-- back to.
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
OverworldBattle.LABEL,
{ true, false }, { "ON", "OFF" })
function OverworldBattle.enabled()
return OverworldBattle.setting:get() and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
-- wrong twice over: it is turned away from the camera that is looking at it,
-- and the back pics are a different, smaller drawing made for a slot the
-- player never really sees. So the player's side asks for the FRONT pic too,
-- through the engine's own pokemon.sprite hook -- the seam that exists for
-- exactly this, so no battle code has to be touched to get it.
--
-- Answered BEFORE a battle exists, because the battler is built before the
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
-- whether one on this map WOULD be -- the row is on, the 3D pass is
-- available, and the map has an arena -- which is the same question with the
-- same answer a moment later. Cached per map, because the arena search walks
-- the whole grid and this runs once per battler.
local staged = { mapId = nil, ok = false }
function OverworldBattle.wantsFront()
if not OverworldBattle.enabled() then return false end
if not Voxel3D.available() then return false end
-- required here rather than through the file's own helper: this runs
-- while a battler is being built, which is before that helper is defined
local g = require("src.core.Game")
local ow = g and g.overworld
if not (ow and ow.map and ow.player) then return false end
if staged.mapId ~= ow.map.id then
local ok, arena = pcall(BattleArena.find, ow.map,
ow.player.cellX, ow.player.cellY,
ow.player.surfing)
staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
end
return staged.ok
end
-- ------- where the engine's own pics stand
--
-- The GB draws the player's back pic with its feet on the text box at row 96
-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
-- points are the pics' FEET, they hold for every species at every scale (the
-- engine's placement helpers pin the bottom edge and the centre), and they
-- are what BattleCam is solved to put the two arena cells under.
--
-- Which makes the pin a subtraction: whatever the drift has done to the
-- camera this frame, each pic moves by its own cell's projected position
-- minus its anchor. At the middle of the drift that is zero.
OverworldBattle.ANCHOR = {
player = { 26, 96 },
enemy = { 124, 56 },
}
-- ------- how big a mon is
--
-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
-- keeps every pixel the artist drew, and the CAMERA is solved so that one
-- overworld square is that big on screen (see BattleCam). The mon fits its
-- tile because the tile was sized to the mon, not the other way round.
OverworldBattle.SLOT_W = { front = 56, back = 32 }
-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
-- the anchors above.
OverworldBattle.HUD_RECT = {
enemy = { 8, 0, 80, 32 },
player = { 72, 56, 88, 40 },
}
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
-- not nest.
local session = nil
local function game()
return require("src.core.Game")
end
-- Put the map's cast back. Both lists are handed back by identity, so
-- anything that captured one before the battle still sees the same table.
local function restoreCast()
if not (session and session.state) then return end
if session.entities then session.state.entities = session.entities end
if session.ghosts then session.state.ghosts = session.ghosts end
session.entities, session.ghosts = nil, nil
end
-- Cull them. The player stays -- they are not an NPC, they are who the
-- battle belongs to, and Fly/surf animations and the save's own capture read
-- state.player through this list.
--
-- Only the DRAW lists are touched, and only while the overworld is frozen
-- underneath a battle: StateStack updates the top state alone, so nothing
-- walks, wanders, triggers or collides against a list that is short for
-- these frames. The originals go back at battle.ended.
local function cullCast(state)
session.entities = state.entities
session.ghosts = state.ghosts
state.entities = { state.player }
state.ghosts = {}
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
-- battle it always did, cast and all.
function OverworldBattle.begin(state, battle)
OverworldBattle.finish()
if not OverworldBattle.enabled() then return false end
if not (state and state.map and state.player) then return false end
if not Voxel3D.available() then return false end
local ok, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
if not (ok and arena) then return false end
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
BattleCam.reset()
return true
end
-- The fallback entry point: a battle that arrived without going through the
-- overworld's own pushBattle (a link battle, a script pushing a BattleState
-- directly). Nothing visible depends on the cull for those -- the wipe has
-- already been and gone -- but the arena still has to be picked.
function OverworldBattle.ensure(battle)
if session then
-- a battle pushed through the overworld reaches begin() before it is
-- built far enough to draw; battle.started is where it is finished
if battle and not session.battle then session.battle = battle end
return
end
local g = game()
local ow = g and g.overworld
if ow and ow.map then OverworldBattle.begin(ow, battle) end
end
-- The arena this battle is staged on, or nil. Read by the shot driver so a
-- screenshot can be labelled with the ground it was taken on.
function OverworldBattle.arena()
return session and session.arena or nil
end
function OverworldBattle.finish()
if not session then return end
restoreCast()
session = nil
Voxel3D.camera = nil
end
-- ------- per-frame
--
-- Driven from the voxel pipeline's update hook, which the engine ticks every
-- frame regardless of which state is on top -- including the frames the
-- transition wipe covers, which is what gets the arena's meshes built before
-- the first battle frame needs them.
--
-- The scene is rendered HERE rather than inside the battle's draw, because
-- update runs with no canvas bound: a 3D pass that binds a depth target and
-- unbinds to the screen when it is done cannot do that in the middle of
-- someone else's frame without putting the frame back itself.
function OverworldBattle.update(dt)
if not session then return end
local g = game()
local top = g and g.stack and g.stack:top()
local ow = g and g.overworld
-- A battle that ended without saying so (a script tearing the state down,
-- a path that never emits battle.ended) would otherwise leave the cast
-- culled for good. Armed only once something has actually covered the
-- overworld, because begin() runs while the overworld is still on top.
if top ~= nil and top ~= ow then
session.armed = true
elseif session.armed then
OverworldBattle.finish()
return
end
BattleCam.update(dt)
-- the battle only exists once it has been pushed; a session opened at
-- pushBattle time has it, one opened from battle.started was handed it
session.battle = session.battle or (top ~= ow and top or nil)
-- the world pass is hidden behind the battle, so mesh builds get the wide
-- slice: nothing visible can hitch on them
ChunkMesher.pump(true)
-- The mons' textures are rendered HERE, with no canvas bound, for the same
-- reason the scene is: the pics layer binds its own targets, and doing that
-- inside somebody else's frame means putting the frame back afterwards.
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
if not okTex then textures = nil end
session.token = (session.token or 0) + 1
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
textures, session.token)
if not ok then
-- One failure retires the arena for THIS battle and nothing else: the
-- battle screen carries on as the engine's own, the free-roam pipeline
-- this runs inside keeps rendering the overworld, and the next battle
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
-- guard, which retires a pipeline for the session.
session.shot = nil
session.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
if shot and shot.canvas then
-- the depth of field is measured off the two marks: the slab in focus is
-- the one the mons are standing in, at whatever the drift has done to
-- where that lands
local y1 = shot.ly + shot.player[2] * shot.scale
local y2 = shot.ly + shot.enemy[2] * shot.scale
local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
focusY, band, range)
if okDof and blurred then shot.canvas = blurred end
-- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
-- so a panel over a blurred far field is frosted from what is actually
-- behind it
pcall(BattleHud.build, shot.canvas)
end
session.shot = shot
end
-- The finished shot for this frame, or nil when there is none and the battle
-- should draw the way it always did.
function OverworldBattle.shot()
if not session or session.broken then return nil end
local s = session.shot
if s and s.canvas then return s end
return nil
end
function OverworldBattle.invalidate()
BattleDOF.invalidate()
BattleHud.invalidate()
BattlePics.invalidate()
end
-- ------- the battle screen's background
--
-- BattleState opens by filling 160x144 white -- that fill IS the battle's
-- background, and in the colorized pipeline it is also the BG canvas's clear
-- (nothing else clears it, so skipping it outright would ghost last frame).
-- So for the length of one draw, that one call is intercepted: on the two
-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
-- composites the HUD and the text box over the arena and leaves the empty
-- field showing it; on the screen it is simply dropped, because the UI canvas
-- has already been cleared transparent for the world to show through.
--
-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
-- move-animation flash (which is white at 0.85) all pass through untouched.
--
-- This is a shim over love.graphics and it is the one invasive thing here,
-- so it is scoped as tightly as it can be: installed around a single call,
-- removed on the way out including on error, and never live outside a battle
-- frame this mode is drawing.
local function withoutBackgroundFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, x, y, w, h, ...)
if mode == "fill" and x == 0 and y == 0
and w == BattleScene.GB_W and h == BattleScene.GB_H then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 then
-- Two different full-frame whites, both replaced rather than drawn.
--
-- OPAQUE is the battle's background, and on the offscreen canvases it
-- doubles as their clear, so there it becomes a transparent one.
--
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
-- flash; over a world it whites out the map, the HUD and the text box
-- together. BattleScene puts it back on the mons alone.
if a > 0.99 then
local target = g.getCanvas()
if target ~= nil
and (target == battle.bgCanvas or target == battle.waveCanvas) then
g.clear(0, 0, 0, 0)
end
end
return
end
end
return rectangle(mode, x, y, w, h, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the mons, as textures for the 3D pass
--
-- The two Pokemon are not composited over the world any more: they are quads
-- standing in it (see BattleBillboard). What that needs from the battle
-- screen is a TEXTURE per side -- and the honest way to get one is to let the
-- engine draw its own pics layer, unchanged, into a canvas.
--
-- So the layer is rendered twice, once per side, with the other side
-- falsified out of existence by nulling exactly the fields its branches
-- test. Everything the engine does to a pic comes along for free that way:
-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
-- faint slide, the damage blink, the squish, every SE displacement. None of
-- it is reimplemented and none of it can drift.
--
-- Two things are forced during that render. The scale, to 1, so the texture
-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
-- placement, so the pic lands centred on a known column with its feet on a
-- known row. That known point is what the quad is then hung from.
local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline
local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
-- Which side is being rendered, or nil. The placement wrappers read it.
local texturing = nil
local texCanvas = {}
local innerPics = nil -- captured by install()
local function texCanvasFor(side)
local c = texCanvas[side]
if c then return c end
local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
BattleScene.GB_H, { dpiscale = 1 })
if not ok then return nil end
made:setFilter("nearest", "nearest")
texCanvas[side] = made
return made
end
-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own
-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or
-- not-yet-sent-out mon simply has no billboard this frame.
local function sideVisible(battle, side)
if side == "enemy" then
if battle.showEnemyTrainer and battle.trainerPic then return true end
return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden
and not battle.enemySendingOut
and not battle:fxHidden(battle.enemy)) and true or false
end
if battle.showPlayerBack and battle.playerBackPic then return true end
local hide = battle.safari or battle.demo
return (battle.player and battle.player.sprite and not hide
and not battle.sendingOut
and not battle:fxHidden(battle.player)) and true or false
end
local OFF = {
enemy = { player = false, showPlayerBack = false },
player = { enemy = false, showEnemyTrainer = false },
}
-- Render one side's pics layer into its canvas and report where the pic's
-- feet ended up, in canvas coordinates.
function OverworldBattle.sideTexture(battle, side)
if not (innerPics and battle) then return nil end
if not sideVisible(battle, side) then return nil end
local canvas = texCanvasFor(side)
if not canvas then return nil end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
-- The pic-window scissors are in the battle screen's fixed coordinates and
-- would clip a pic that has been moved to the middle of its own canvas.
-- There is nothing here for them to protect -- no HUD, no text box, just
-- the one pic -- so they are switched off for the render.
local setScissor, intersectScissor = g.setScissor, g.intersectScissor
local getScissor = g.getScissor
g.setScissor = function() end
g.intersectScissor = function() end
g.getScissor = function() return nil end
local saved = {}
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
texturing = side
local ok, err = pcall(function()
g.setCanvas(canvas)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
innerPics(battle, 0, 0, 0)
end)
texturing = nil
for k in pairs(OFF[side]) do battle[k] = saved[k] end
g.setScissor, g.intersectScissor, g.getScissor =
setScissor, intersectScissor, getScissor
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
if not ok then error(err, 0) end
local ax, ay = TEX_AX, TEX_AY
local trainer = false
-- The intro trainer pic draws itself straight into its own 7x7 slot rather
-- than through the placement helpers, so it is hung from that slot instead.
if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then
ax, ay, trainer = TRAINER_AX, TRAINER_AY, true
elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then
trainer = true
end
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
end
-- Whether the hit flash is showing this frame.
--
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
-- decision there (a counter plus the frame parity that makes it flicker) and
-- there is no seam that reports it. Read-only, so the worst a future engine
-- change can do is flash on a frame the engine would not have.
function OverworldBattle.flashing(battle)
local fx = battle and battle.fx
if not (fx and fx.flash and fx.flash > 0) then return false end
return (battle.frame or 0) % 4 < 2
end
-- Both sides, or nil when neither has anything to show.
function OverworldBattle.textures(battle)
if not battle then return nil end
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
out.flash = OverworldBattle.flashing(battle)
return out
end
-- ------- engine seams
--
-- Four wraps, each idempotent so a hot reload cannot stack them.
function OverworldBattle.install()
local OverworldState = require("src.world.OverworldController")
if not OverworldState.dramaticShapeBattleHook then
local inner = OverworldState.pushBattle
-- The one place the overworld starts a battle, and it runs BEFORE the
-- transition is pushed -- which is what lets the cull happen off-screen
-- and the wipe play over a map with nobody on it.
function OverworldState:pushBattle(battle)
pcall(OverworldBattle.begin, self, battle)
return inner(self, battle)
end
OverworldState.dramaticShapeBattleHook = true
end
local BattleState = require("src.battle.BattleState")
if BattleState.dramaticShapeBattleHook then return end
-- Integer scales only. The camera is solved to make one overworld square
-- exactly big enough for a pic at its own integer scale (see BattleCam), so
-- the fit never has to come out of the pixels -- and a species override or
-- a battle_sprite_scales entry that asks for 1.7x would undo that and
-- resample the sprite into mush. Rounded rather than refused, so such a mod
-- still gets the bigger or smaller mon it asked for, on the pixel grid.
local innerScale = BattleState.resolveBattleScale
function BattleState.resolveBattleScale(data, side, path, species)
local base = innerScale(data, side, path, species)
-- 1:1 into the billboard texture: the artwork's own pixels, with the
-- quad's world size doing every bit of the scaling. Anything else would
-- resample the sprite twice -- once into the texture and again on the way
-- to the screen -- and a twice-resampled Gen 1 pic is mush.
if texturing then return 1 end
if not OverworldBattle.shot() then return base end
return math.max(1, math.floor((tonumber(base) or 1) + 0.5))
end
-- Keyed-out whites inside a pic used to be filled by the white field
-- behind it. There is a world back there now, so they are filled here
-- instead -- see BattlePics, which puts the paper back without touching
-- the silhouette.
local innerPic = BattleState.picImage
function BattleState:picImage(img)
local out = innerPic(self, img)
if not OverworldBattle.shot() then return out end
return BattlePics.filled(out)
end
-- While a billboard texture is being rendered both pics are put in the same
-- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad
-- has one anchor to hang from whichever side and whichever species it is
-- carrying. Outside that render both helpers answer exactly as they always
-- did.
local innerBack = BattleState.backPlacement
function BattleState.backPlacement(w, h, pad, padL, scale)
local x, y, s = innerBack(w, h, pad, padL, scale)
if not texturing then return x, y, s end
return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s
end
local innerFront = BattleState.frontPlacement
function BattleState.frontPlacement(ex, ey, w, h, scale)
local x, y, s = innerFront(ex, ey, w, h, scale)
if not texturing then return x, y, s end
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
end
local innerDraw = BattleState.draw
function BattleState:draw()
local shot = OverworldBattle.shot()
-- AskName blanks the field on purpose (the nickname prompt is meant to
-- sit on nothing); leave that one alone.
if not shot or self.blankForAskName then
-- nil, not false: the class default is inherited again, so a battle
-- that loses its arena mid-fight goes back to white voids
self.letterboxWhite = nil
self.dramaticShapeShot = nil
return innerDraw(self)
end
self.dramaticShapeShot = shot
-- The world reaches the screen through the seam a render pipeline's
-- finished world image already uses: one window-resolution canvas,
-- blitted a pixel to a pixel, with the 160x144 UI canvas composited over
-- it in the classic letterbox afterwards. That is what makes the backdrop
-- as crisp as the free-roam diorama while the pics and text stay GB art.
local renderer = game().renderer
if renderer and renderer.setWorldOverride then
renderer:setWorldOverride(shot.canvas)
end
-- beginFrame clears the UI canvas white for an opaque state; the world is
-- under it now, so clear it back to nothing and let it through. Safe to
-- do here: an opaque battle is the lowest state drawn, so nothing has
-- drawn into this canvas yet.
love.graphics.clear(0, 0, 0, 0)
-- the white letterbox exists so the window matches the white battle
-- canvas; there is a world out to the window edges now
self.letterboxWhite = false
OverworldBattle.drawHudPanels(self)
withoutBackgroundFill(self, innerDraw)
end
-- The mons are geometry standing on the map now, drawn in the 3D pass
-- before this screen is composited at all, so the flat pics layer has
-- nothing left to do here. Skipped rather than left to draw underneath, or
-- every Pokemon would appear twice: once on its tile and once in its slot.
innerPics = BattleState.drawPicsLayer
function BattleState:drawPicsLayer(slide, sx, sy)
if self.dramaticShapeShot then return end
return innerPics(self, slide, sx, sy)
end
-- Move animations are authored against the pics' fixed slots, and a single
-- animation reaches across both sides, so there is no per-side offset to
-- give them. They ride the average, which is where the pair's centre went
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
-- aimed at instead of drifting off it.
local innerAnim = BattleState.drawAnimLayer
function BattleState:drawAnimLayer(colorized)
local shot = self.dramaticShapeShot
if not shot then return innerAnim(self, colorized) end
-- Move animations are authored against the pics' old fixed slots, and one
-- animation reaches across both sides, so there is no per-side offset to
-- give them. They ride to where the PAIR went: the midpoint of the two
-- mons' projected positions, less the midpoint of the slots they used to
-- sit in. A hit still lands on the mon it is aimed at.
local a = OverworldBattle.ANCHOR
local dx = (shot.enemy[1] + shot.player[1]) / 2
- (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + shot.player[2]) / 2
- (a.enemy[2] + a.player[2]) / 2
love.graphics.push()
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
local ok, err = pcall(innerAnim, self, colorized)
love.graphics.pop()
if not ok then error(err, 0) end
end
-- The engine's flash has a SECOND half, and it is the one that reaches the
-- menu. Beside the white rectangle (dropped above) the flash moves are
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
-- colorized pipeline applies in drawZonePass to the WHOLE background
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
-- meant for the two mons washed the menu out with them.
--
-- The fade is left switched on for the pics, which read it through
-- picImage, and switched off for the zone pass alone. So the mons flash
-- and the furniture around them does not.
--
-- The zone pass has a SECOND thing it paints, and this is the one that
-- reads as the menu box flashing. A screen shake makes it fill every zone
-- with the zone's own color 0 before it draws the offset copy -- the
-- hardware showing empty BG in the strip the shake vacated. On a white
-- battle field that fill is invisible; over a world it is an opaque white
-- sheet across the whole frame, and since a shake program alternates
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
-- switches on and off a few times a second. It is dropped: the background
-- here is the map, so what the shake vacates should show the map.
local innerZone = BattleState.drawZonePass
function BattleState:drawZonePass(src, sx, sy)
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
-- shadow the method on the instance for this call only; putting the
-- field back to whatever it was (normally nil) lets the class method be
-- found again
local had = rawget(self, "activeBgp")
self.activeBgp = function() return nil end
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
-- the pass draws no other rectangle; the shake still shifts the copy
if mode == "fill" then return end
return rectangle(mode, ...)
end
local ok, err = pcall(innerZone, self, src, sx, sy)
g.rectangle = rectangle
self.activeBgp = had
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
-- the colorized pipeline this lands in the grayscale BG canvas, where
-- white IS shade 0 and the zone pass then colours it like every other
-- lightest-shade surface. One rule, both pipelines.
--
-- The HP bar is untouched: it is drawn in its own greens and reds, and
-- only an exactly-black set is remapped.
local innerHUDs = BattleState.drawHUDs
function BattleState:drawHUDs(slide)
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
return innerHUDs(self, slide)
end
local battle = self
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
innerHUDs(battle, slide)
end)
end
BattleState.dramaticShapeBattleHook = true
end
-- Whether each HUD block is on screen this frame.
--
-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam
-- that reports "the enemy HUD is up". A panel under a HUD that is not there
-- would be a frosted slab floating in the arena, so it is worth mirroring;
-- the worst a future engine change can do is show an empty one for a frame,
-- never break a battle.
function OverworldBattle.hudLive(battle, slide)
local enemy = battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut
and not battle:growInScale(battle.enemy) and slide == 0
and not battle.enemy.fainted
local player = battle.player and not (battle.safari or battle.demo)
and not battle.showPlayerBack and slide == 0
return enemy and true or false, player and true or false
end
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot then return end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
local rect = OverworldBattle.HUD_RECT
local live = {}
if enemy then live.enemy = rect.enemy end
if player then live.player = rect.player end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
end
return OverworldBattle
+87 -6
View File
@@ -232,7 +232,15 @@ local SHADER = [[
-- Each entry is nil = untried, false = unavailable.
local shaders = { [false] = nil, [true] = nil }
local activeShader = nil -- the variant this pass bound
local canvas, canvasW, canvasH = nil, 0, 0
-- Scene canvases, one per NAMED SLOT. There are exactly two callers and
-- they want different sizes -- the free-roam pass renders at the window's
-- pixel dimensions, the overworld battle at the GB's 160x144 -- and a
-- single cached canvas made every battle entry and exit reallocate one.
-- A slot reallocates only when its OWN size changes, which is a window
-- resize, so the pair is stable for a session.
local slots = {}
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
local active = false
local IDENTITY = Mat4.identity()
@@ -312,9 +320,44 @@ end
-- ---------------------------------------------------------------- camera --
-- An explicit camera, replacing the orbit below for as long as it is set:
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
--
-- The orbit is the free-roam camera and it is described entirely by ONE
-- number, the pitch, because that is all a camera following the player over
-- their own map ever needs. A staged shot -- the overworld battle's
-- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw,
-- it does not sit above its focus, and its framing comes from the arena
-- rather than from the view size. Rather than widen the orbit into
-- something that could express both and be the wrong shape for each, a
-- caller with a camera of its own simply hands it over.
--
-- Everything downstream is unchanged by this: the shader uniforms, project()
-- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same
-- way either way.
Voxel3D.camera = nil
-- View and projection for a `vw` x `vh` world-pixel view centred on
-- (cx, cy) in world pixels. Returns the combined matrix.
function Voxel3D.viewProjection(cx, cy, vw, vh)
local cam = Voxel3D.camera
if cam then
local eye, focus = cam.eye, cam.focus
Voxel3D.eye = eye
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
local proj = Mat4.perspective(cam.fov, vw / vh,
math.max(1, dist * 0.05), dist * 4 + 4096)
-- the same clip-space Y flip the orbit needs, for the same reason: we
-- bypass LOVE's transform_projection and canvas coordinates run Y down
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
-- world up, so the horizon stays level -- a placed camera that rolled
-- with its own pitch would tip the whole arena
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
end
local a = Voxel.angle
local focal = Voxel.FOCAL
local dist = focal * vh
@@ -351,7 +394,9 @@ end
-- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the
-- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the
-- void transparent, which is what every rung below it wants.
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
-- `slot` names which cached canvas to render into (see `slots` above);
-- omitted is the free-roam world pass.
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- the wireframe variant when the player has it on AND it built; either
-- answer falls through to the plain scene rather than to no scene
local grid = VoxelGrid.enabled()
@@ -360,12 +405,19 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
grid, sh = false, Voxel3D.shader()
end
if not sh then return false end
if not canvas or canvasW ~= w or canvasH ~= h then
local name = slot or "world"
local held = slots[name]
if not (held and held.w == w and held.h == h) then
local ok, c = pcall(love.graphics.newCanvas, w, h)
if not ok then return false end
c:setFilter("nearest", "nearest")
canvas, canvasW, canvasH = c, w, h
if held and held.canvas and held.canvas.release then
pcall(held.canvas.release, held.canvas)
end
held = { canvas = c, w = w, h = h }
slots[name] = held
end
canvas, canvasW, canvasH = held.canvas, w, h
-- a depth buffer is what makes occlusion real: walk behind a building and
-- the building wins, with no y-sorting anywhere
local ok = pcall(love.graphics.setCanvas,
@@ -411,8 +463,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
pcall(sh.send, sh, "ghost", 0)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
-- the curved world bends about the camera's focus, so the horizon keeps
-- a fixed distance ahead of the player rather than sitting on the map
Voxel3D.curveK = WorldCurve.k(vh)
-- a fixed distance ahead of the player rather than sitting on the map.
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
local placed = Voxel3D.camera
Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh)
Voxel3D.curveX, Voxel3D.curveZ = cx, cy
pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK })
-- clip w at the focus point, the reference depth project() reports scale
@@ -483,6 +537,27 @@ function Voxel3D.beginGhost()
end
end
-- Flatten whatever is drawn next to one solid colour, or nil to stop.
--
-- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted
-- depth test and half alpha -- this is for something drawn normally that
-- simply wants to come out one colour, which is what a hit flash on a sprite
-- is. beginScene resets the uniform every frame, so a pass that forgets to
-- clear it cannot leak into the next one.
-- `amount` is how far toward that colour, 0..1; omitted is all the way.
-- Anything short of 1 leaves the sprite's own shading showing through, which
-- is the difference between a hit flash and a white cut-out.
function Voxel3D.flatten(color, amount)
if not (active and activeShader) then return end
local sh = activeShader
if color then
pcall(sh.send, sh, "ghostColor", color)
pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1)))
else
pcall(sh.send, sh, "ghost", 0)
end
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
@@ -658,6 +733,12 @@ end
-- Drop the GPU objects (window resize, hot reload).
function Voxel3D.invalidate()
for name, held in pairs(slots) do
if held.canvas and held.canvas.release then
pcall(held.canvas.release, held.canvas)
end
slots[name] = nil
end
canvas, canvasW, canvasH = nil, 0, 0
ShadowMap.invalidate()
end
+12
View File
@@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
{ false, true }, { "OFF", "ON" })
-- A pass that needs the wireframe whatever the player left the row on sets
-- this for the length of its own draw and puts it back after. nil means
-- "follow the setting", which is every frame outside such a pass.
--
-- The overworld battle is the one user: a fight is a STAGED shot, not the
-- world being walked around in, and the seams are what make it read as
-- constructed rather than as a photograph of somewhere. The row still owns
-- what free-roam looks like, and is not written to -- switching the mode off
-- mid-battle would silently rewrite the player's own setting.
VoxelGrid.override = nil
function VoxelGrid.enabled()
if VoxelGrid.override ~= nil then return VoxelGrid.override end
return VoxelGrid.setting:get() and true or false
end
+21 -6
View File
@@ -84,13 +84,25 @@ local function skyStrength(angleRad)
return t
end
local function skyFor(map)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
local t = skyStrength(Voxel.angle)
if t <= 0 then return nil end
-- One shade off the sky ramp, transformed by the display mode, as an
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
-- proper; 4 is its darkest, which is what an indoor void wants).
function VoxelScene.skyShade(shade, alpha)
local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
local c = shades[SKY_SHADE] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, t }
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
end
-- The sky `map` stands under at strength `t`, or nil where there is no sky
-- to paint: indoors, or with the horizon out of frame.
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
return VoxelScene.skyShade(SKY_SHADE, t)
end
local function skyFor(map)
return VoxelScene.skyColor(map, skyStrength(Voxel.angle))
end
VoxelScene._skyFor = skyFor -- named for the suite
@@ -131,6 +143,9 @@ local function groundAt(map, cellX, cellY)
end
VoxelScene.YAW = YAW
-- shared with the overworld battle, which stands its mons on map cells and
-- needs the same answer about what height "the floor" is there
VoxelScene.groundAt = groundAt
-- Camera-ward pull distance for billboards (and the grass rows, which
-- must keep their relative depth to feet): just enough that a leaned-back
+52 -2
View File
@@ -23,10 +23,60 @@
local Voxel = {}
Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" }
-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
-- its intended state at once -- this camera, the miniature blur at full, the
-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
-- it rather than assembling it from four rows. It sits directly after OFF
-- because that is the order those two get used in.
--
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
-- like, and two rungs may look the same while meaning different things.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
-- the rung FULL sits on, so nothing has to hunt for it by label
Voxel.FULL_LEVEL = 1
function Voxel.isFull(level)
return (level or Voxel.level) == Voxel.FULL_LEVEL
end
-- ------- what the hotkey walks
--
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
-- cycler: pressing it should change the camera and nothing else, and FULL
-- reaches in and rewrites four other settings. Landing on it by accident,
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
-- row, which is where a preset that changes other rows belongs.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
-- The rung a press moves to from `level`.
--
-- A level that is not on the key's path -- FULL, reached from the menu --
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
-- rung, so retuning FULL moves the key's answer with it.
function Voxel.nextHotkeyLevel(level)
level = level or Voxel.level
local order = Voxel.HOTKEY_ORDER
local at = nil
for i, rung in ipairs(order) do
if rung == level then at = i break end
end
if not at then
local deg = Voxel.ANGLES_DEG[level + 1]
for i, rung in ipairs(order) do
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
end
end
if not at then return order[1] end
return order[at % #order + 1]
end
Voxel.level = 0
Voxel.angle = 0
Voxel.from = 0
+216 -12
View File
@@ -77,6 +77,13 @@ local TiltShift = V.require("TiltShift")
local ChunkMesher = V.require("ChunkMesher")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
-- Declared rather than left global -- a mod writing to _G would leak into
-- every other mod's namespace.
local applyFull
-- The last VOID FILL the terrain was meshed under; see the update hook.
-- The scene canvas's size, in FRAMEBUFFER PIXELS.
@@ -144,7 +151,21 @@ mod.content.render_pipelines:register("voxel", {
-- pump slice -- so stepping out of a door lands on terrain that is
-- already there instead of a flat flash.
update = function(dt, level)
-- FULL is a preset, so it is applied ON THE PRESS rather than held every
-- frame: it SETS the other rows and then leaves them alone. Holding them
-- would make the zoom keys and the wheel dead while the mode was on, and
-- would fight anyone who changed one deliberately.
applyFull(level)
Voxel.update(dt, level)
-- 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.
-- What it needs is a tick that keeps running once the overworld stops
-- being the top state, and this is one -- Game:update calls
-- Pipelines.update unconditionally, so it survives the transition wipe
-- and the whole battle. Ahead of the active() gate below, because a 3D
-- battle does not require the free-roam mode to be switched on.
OverworldBattle.update(dt)
-- VOID FILL picks the block the border ring is made of, and in this
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
-- So the option has to reach the cache or nothing happens on screen
@@ -186,6 +207,7 @@ mod.content.render_pipelines:register("voxel", {
invalidate = function()
Voxel3D.invalidate()
OverworldBattle.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
end,
})
@@ -222,10 +244,53 @@ mod.content.render_pipelines:register("tiltshift", {
-- instead -- see ModSetting for where they persist and how the two rows
-- each ends up on stay in step.
-- ------- the FULL preset
--
-- Everything the mode wants switched to at once. Applied when the VOXEL row
-- ARRIVES at FULL and not again, so the player can still move the camera or
-- the zoom afterwards -- it is a starting point, not a lock.
--
-- Leaving FULL deliberately does NOT undo any of it. A preset that reverted
-- would throw away whatever the player had changed since, and "put it back
-- how it was" is not a thing this can know.
local fullWas = nil
applyFull = function(level)
local isFull = Voxel.isFull(level)
local was = fullWas
fullWas = isFull
if not isFull or was == true or was == nil then return end
local Game = require("src.core.Game")
local Pipelines = require("src.render.Pipelines")
local Zoom = require("src.render.Zoom")
local opts = Game.save and Game.save.options
if not opts then return end
-- the miniature blur at its strongest: FULL is the diorama look, and the
-- tilt-shift is most of what makes it read as a model
Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift"))
Pipelines.syncOptions(opts)
-- the horizon flat. The curve bends the world away from a walking player,
-- which fights a fixed diorama framing
WorldCurve.setting:setIndex(1, Game)
-- and the view fitted to the window
opts.zoom = 0
Zoom.applyOptions(opts)
-- battles on the map too: FULL means the whole mode, and a fight is where
-- half of it is spent. Set rather than forced -- the row is gone from the
-- menu while FULL is on, but a save that already had it off gets it on.
OverworldBattle.setting:setIndex(1, Game)
if Game.writeOptions then pcall(Game.writeOptions, Game) end
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift." },
}
local schema = {}
@@ -236,17 +301,18 @@ mod.options:define(schema)
-- ------- this mod's hotkeys
--
-- 3 VOXEL cycle the camera ladder (was 6)
-- 3 VOXEL cycle the camera ladder (was 6; skips FULL)
-- 5 V-GRID toggle the wireframe (new)
-- 6 T-SHIFT cycle the blur ladder (was 9)
-- 7 V-CURVE cycle the horizon bend (new)
-- 8 3D-BTL toggle overworld battles (new)
--
-- Only 6 arrives by the documented route. Game:keypressed answers the
-- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM,
-- 5 GBC FX -- and only then offers the key to Pipelines.hotkey, expressly
-- so "a pipeline can never shadow one" (Schemas, render_pipelines.hotkey).
-- 3 and 5 are two of those, and 7 belongs to a pair of plain mod settings
-- that own no pass and so have no registry to claim a key from at all.
-- 3 and 5 are two of those, and 7 and 8 belong to plain mod settings that
-- own no pass and so have no registry to claim a key from at all.
--
-- So this wraps Game:keypressed. It is the invasive option and it is the
-- only one: polling the keyboard in update() would fire alongside the
@@ -268,6 +334,7 @@ local HOTKEYS = {
["6"] = "pipeline", -- tiltshift, likewise
["5"] = VoxelGrid.setting,
["7"] = WorldCurve.setting,
["8"] = OverworldBattle.setting,
}
do
@@ -283,7 +350,20 @@ do
-- render mode. Only free-roam presses are ours to take.
if claim and not (top and top.onKeyPressed) then
if claim == "pipeline" then
if Pipelines.hotkey(key, top, self.overworld) then
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
-- so the registry's plain "advance one and wrap" is not what it
-- wants; 6 still is. The gate is the registry's own either way.
local stepped = false
if key == "3" then
if Pipelines.canToggle("voxel", top, self.overworld) then
Pipelines.setLevel("voxel",
Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
stepped = true
end
else
stepped = Pipelines.hotkey(key, top, self.overworld) and true
end
if stepped then
Pipelines.syncOptions(self.save.options)
-- 3 is the key that used to turn TILT on and sits next to the one
-- that used to turn GBC FX on, and this mod has taken both away.
@@ -304,10 +384,14 @@ do
return
end
elseif Pipelines.canToggle("voxel", top, self.overworld) then
-- Both settings parameterise the voxel pass, so they answer to the
-- same free-roam gate it does -- borrowed from the registry rather
-- than restated, so a press mid-warp or mid-cutscene is refused for
-- the wireframe exactly when it would be for the mode itself.
-- All three answer to the voxel pass's own free-roam gate --
-- borrowed from the registry rather than restated, so a press
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
-- it would be for the mode itself. Two of them parameterise that
-- pass; the third (3D-BTL) decides what a battle is drawn over, and
-- wants the same gate for a different reason: the answer is read
-- when the fight starts, so flipping it from inside one would be a
-- switch that appeared to do nothing.
claim:cycle(self)
return
end
@@ -316,15 +400,54 @@ do
end
end
-- ------- the mode's rows, kept together
--
-- The engine splices a pipeline's row in beside TILT, because a display mode
-- belongs with the other display modes; a mod's own ui.options.rows
-- additions land at the END of the list. That left this mod's four rows in
-- two places with unrelated engine rows between them, which reads as two
-- unrelated features rather than one mode with settings.
--
-- So the plain settings are inserted directly after the last of this mod's
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
-- where the engine already decided display modes go.
local function insertGrouped(out, extra)
local anchor = nil
for i, row in ipairs(out) do
local id = type(row) == "table" and row.id
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
end
if not anchor then
for _, row in ipairs(extra) do out[#out + 1] = row end
return out
end
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
return out
end
-- FULL owns every one of those settings, so while it is selected they are
-- taken off the menu rather than left to be changed under it -- including
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
-- longer decides anything is worse than no row.
local function dropRow(out, id)
for i = #out, 1, -1 do
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
end
return out
end
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
for _, entry in ipairs(SETTINGS) do
out[#out + 1] = entry[1]:row()
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then
return dropRow(out, "pipeline:tiltshift")
end
return out
local extra = {}
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
return insertGrouped(out, extra)
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -416,7 +539,88 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
mod.exports.version = "1.0.6"
-- ------- FULL takes rows off the menu, so the menu has to notice
--
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
-- that list every frame. So stepping the VOXEL row onto or off FULL changed
-- which rows the hook would return but not which rows were on screen -- the
-- settings FULL owns stayed visible until the menu was closed and reopened,
-- and a player who stepped off FULL could not see the rows come back.
--
-- Rebuilt in place, and only on a step that crosses FULL: every other rung
-- returns the same list, and rebuilding on all of them would rerun every
-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
-- than reset, so it stays on the VOXEL row it was just used on instead of
-- jumping to the top when the list below it shortens.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
inner(self, dt)
local after = Pipelines.level("voxel")
if after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after)) then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
local cancel = #self.rows + 1
if (self.index or 1) > cancel then self.index = cancel end
end
end
OptionsMenu.dramaticShapeFullHook = true
end
end
-- ------- battles on the map
--
-- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and
-- BattleState:drawHUDs -- all live in lib/OverworldBattle.lua, which is
-- where the reasoning for each one is written down. Installed once, here,
-- so this file keeps naming every engine seam the mod touches.
OverworldBattle.install()
-- The overworld's own pushBattle is the choke point for a wild encounter or
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
-- link battle, a script pushing a BattleState directly -- reaches this
-- instead, which stages the arena from wherever the player is standing.
-- Nothing visible is lost by being late: the cull only has to beat the
-- battle screen, and the wipe those battles skip is where it would have
-- shown.
mod.events:on("battle.started", function(payload)
OverworldBattle.ensure(payload and payload.battle)
end)
-- Both mons face the camera, so the player's side wants its FRONT pic where
-- the battle screen would have used the back one. The engine's own
-- pokemon.sprite hook is the seam for exactly this: it is asked for every
-- battle pic with the side it is resolving, so swapping one side's answer
-- needs no battle code at all -- and every path that builds a battler goes
-- through it, including a Transform mid-fight.
--
-- next() first, so a sprite-replacing mod loaded before this one still gets
-- the last word on WHICH art is used; this only changes which SIDE is asked
-- for.
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
local out = next(path, ctx)
if not (ctx and ctx.kind == "battle" and ctx.side == "back") then
return out
end
if not OverworldBattle.wantsFront() then return out end
local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
return (def and def.spriteFront) or out
end)
-- Every ending path emits this, including a battle skipped before it drew,
-- so this is where the map's cast comes back.
mod.events:on("battle.ended", function()
OverworldBattle.finish()
end)
mod.exports.version = "1.1.1"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+5 -3
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.0.6",
"version": "1.1.1",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -11,7 +11,9 @@
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"permissions": ["engine_internals"],
"permissions": [
"engine_internals"
],
"affects_link": false,
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines and claims hotkeys 3, 5, 6 and 7 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only."
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
}
+13 -4
View File
@@ -1,14 +1,18 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "The overworld as a 3D diorama: extruded terrain, real occlusion, voxel characters, tilt-shift miniature blur.",
summary = "The overworld as a 3D diorama, and battles fought on it: real occlusion, tilt-shift, over-the-shoulder fights.",
author = "DramaticShape",
contact = "https://github.com/DramaticShape/DRAMATIC_SHAPE",
tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation" },
tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation",
"battle" },
differences = {
changed = {
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
@@ -17,12 +21,17 @@ return {
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
},
known = {
"needs shader and depth-canvas support; without them the row still cycles but the world stays 2D",
"battles, menus and cutscenes are unaffected -- the mode only draws the free-roam overworld",
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
},
},
+233
View File
@@ -0,0 +1,233 @@
-- Driver: choose and photograph one battle arena per map.
--
-- data/battle_arenas.lua holds one authored spot per area. This is what
-- authors it: for every map a battle can happen on, it asks BattleArena for
-- the arena nearest the map's middle that its clearance test says both mons
-- can be SEEN in, stages a real battle there, and takes one screenshot.
--
-- Two outputs. A `PICK` line per map, ready to paste into the data file, and
-- a PNG per map to look at -- because the clearance test answers a geometry
-- question ("nothing tall on the sightline") and the actual question is
-- whether the picture reads. Grass and flowers around a mon's feet are fine
-- and wanted; a body cut in half by a wall is not, and only an eye catches
-- the difference.
--
-- SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
-- centred in the map's biggest body of it, which is what the surf routes
-- want. SHOT_DIR must already exist -- the capture writes with io.open,
-- which does not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
game.save.player.name = "RED"
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 Arena = lib.require("BattleArena")
local Battles = lib.require("OverworldBattle")
-- ------- staging out on the water
--
-- A surf route's land is a rim of beach round the edge of the map, so the
-- ordinary search always picks the rim and the fight happens on sand at
-- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
-- counts as ground, and the search starts from the middle of the map's
-- BIGGEST body of water rather than the middle of the map, so the arena
-- lands out in the open sea instead of against the first shoreline it
-- finds.
--
-- Biggest body, not all water at once: a map with a lake and an ocean has
-- a centroid between them that is on neither, and the arena would be
-- pinned to whichever shore that landed nearest.
local function waterCentre(map)
local w, h = map.widthCells, map.heightCells
local seen, best = {}, nil
for y0 = 0, h - 1 do
for x0 = 0, w - 1 do
if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
-- one connected body, flooded from this cell
local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
seen[y0 * w + x0] = true
while #stack > 0 do
local cell = table.remove(stack)
local cx, cy = cell[1], cell[2]
n, sx, sy = n + 1, sx + cx, sy + cy
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
local nx, ny = cx + d[1], cy + d[2]
local key = ny * w + nx
if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
and map:isWaterCell(nx, ny) then
seen[key] = true
stack[#stack + 1] = { nx, ny }
end
end
end
if not best or n > best.n then
best = { n = n, x = sx / n, y = sy / n }
end
end
end
end
return best
end
-- Which maps a battle can actually happen on: anything with a wild
-- encounter table or an object that fights. Everything else -- a shop
-- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
-- that never happens there.
--
-- The encounter tables are their OWN registry keyed by map id, not a field
-- on the map record. An earlier cut of this read `def.encounters`, which is
-- always nil -- so the list silently narrowed to trainer maps only and
-- every route, cave and Safari zone fell out of it. Nothing failed; the
-- tool just quietly stopped offering to author the places wild battles
-- actually happen.
local function fights(id, def)
local enc = game.data.encounters and game.data.encounters[id]
if enc then
for _, kind in ipairs({ "grass", "water" }) do
local t = enc[kind]
if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
return true
end
end
end
for _, obj in ipairs(def.objects or {}) do
if obj.trainer or obj.trainerClass or obj.species then return true end
end
return false
end
local ids = {}
local explicit = os.getenv("ARENA_MAPS")
if explicit and explicit ~= "" then
for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
else
for id, def in pairs(game.data.maps) do
if type(id) == "string" and type(def) == "table" and fights(id, def) then
ids[#ids + 1] = id
end
end
table.sort(ids)
end
local from = tonumber(os.getenv("ARENA_FROM") or "") or 1
local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids
U.log(("%d battle maps; doing %d..%d"):format(#ids, from,
math.min(#ids, from + count - 1)))
-- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter
-- picks is the authoritative answer to "which areas need an arena", so it
-- has to be readable without sitting through a capture -- that is how the
-- bug above went unnoticed.
if count <= 0 then
for i = 1, #ids, 8 do
U.log("LIST " .. table.concat(ids, ",", i, math.min(#ids, i + 7)))
end
U.log("done -- listing only")
return
end
for i = from, math.min(#ids, from + count - 1) do
local id = ids[i]
local def = game.data.maps[id]
local cx = math.floor(def.width) -- the middle, in cells
local cy = math.floor(def.height)
-- the pick is made against the MAP, before anything is staged, so a map
-- with no clear arena at all is reported rather than quietly given a bad
-- one
local ok, err = pcall(function()
U.teleport(game, id, 1, 1, "down")
end)
if not ok then
U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
else
local map = game.overworld.map
local clear, any
-- ARENA_AT=x,y[,shape] photographs one specific spot instead of the
-- picked one, which is how a map the automatic choice got wrong is
-- re-aimed by hand and checked in the same loop.
-- ARENA_MAP=OTHER_ID stages on another floor of the same cave or
-- building, for a map that has nowhere of its own to put a fight
local at = os.getenv("ARENA_AT")
local onMap = os.getenv("ARENA_MAP")
if at and at ~= "" and (explicit and explicit ~= "") then
local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$")
if ax then
-- forced through the authored-entry seam, so what gets staged is
-- exactly what was asked for rather than whatever the search
-- would have picked from the player's cell
local onCam = os.getenv("ARENA_CAM")
Arena.setOverride(id, {
x = tonumber(ax), y = tonumber(ay),
shape = (ashape ~= "" and ashape) or "wide",
map = (onMap ~= "" and onMap) or nil,
cam = (onCam and onCam ~= "" and onCam) or nil,
})
any = Arena.find(map, 0, 0, false)
clear = any and Arena.clearance(any.map or map, any) or false
end
end
local surf = os.getenv("ARENA_SURF")
surf = surf ~= nil and surf ~= "" and surf ~= "0"
if not any then
local ox, oy = cx, cy
if surf then
local sea = waterCentre(map)
if sea then
ox, oy = sea.x, sea.y
U.log(("SEA %s: biggest body is %d cells, centre %.1f,%.1f")
:format(id, sea.n, sea.x, sea.y))
else
U.log("SEA " .. id .. ": no water on this map")
end
end
clear = Arena.search(map, ox, oy, surf, true)
any = clear or Arena.search(map, ox, oy, surf)
end
if not any then
U.log(("NONE %s -- no arena of either shape"):format(id))
else
local onOther = any.map and any.map.id ~= id and any.map.id or nil
U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s },%s")
:format(id, onOther and ("map = %q, "):format(onOther) or "",
any.x, any.y, any.shape,
any.cam and (", cam = %q"):format(any.cam) or "",
clear and "" or " -- OBSTRUCTED: no clear spot"))
-- stand the player on the arena so the staged battle uses it, then
-- fight something there and photograph the result
game.overworld.player.cellX = any.playerCell[1]
game.overworld.player.cellY = any.playerCell[2]
U.wait(90) -- let the meshes land
local battle = BattleState.newWild(game, "NIDORINO", 20)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local got = Battles.arena()
U.log(("SHOT %s at %s,%s"):format(id,
tostring(got and got.x), tostring(got and got.y)))
U.shot(game, ("%s/%s.png"):format(DIR, id:lower()))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(6)
end
end
end
U.log("done -- " .. DIR)
end
+130
View File
@@ -0,0 +1,130 @@
-- Driver: screenshot an overworld battle against eight random trainers.
--
-- The mode's whole claim is that a fight reads as happening on the map, so
-- what has to be looked at is a spread of maps: open route, town, forest,
-- cave, gym floor. Each one gets the same three beats -- the menu (both HUD
-- panels up over whatever ground they landed on), a move animation, and the
-- frame a few seconds later, which is where the parallax drift shows.
--
-- The eight are drawn with a fixed seed, so a re-run after a tweak produces
-- the same eight battles and the shots compare directly.
--
-- SHOT_DIR=.scratchpad POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/battle_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local SEED = tonumber(os.getenv("SHOT_SEED") or "") or 20260727
love.math.setRandomSeed(SEED)
-- Somebody to fight with. Two very different back pics, so a pin that is
-- wrong for one silhouette and right for another cannot hide.
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
game.save.player.name = "RED"
-- Where to stage them: outdoor, wooded, urban, underground, indoor. Any id
-- the merged dataset does not have is dropped rather than guessed at.
local PLACES = {
{ "ROUTE_1", 5, 8 },
{ "VIRIDIAN_FOREST", 12, 20 },
{ "PALLET_TOWN", 5, 6 },
{ "MT_MOON_1F", 12, 12 },
{ "CERULEAN_CITY", 10, 12 },
{ "ROUTE_25", 12, 5 },
{ "PEWTER_GYM", 4, 8 },
{ "ROUTE_4", 10, 5 },
{ "VIRIDIAN_CITY", 20, 20 },
{ "ROUTE_3", 10, 5 },
}
local places = {}
for _, p in ipairs(PLACES) do
if game.data.maps[p[1]] then places[#places + 1] = p end
end
-- every trainer class the merged data carries, in a stable order so the
-- seed picks the same ones every run
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
U.log(("%d trainer classes, %d places, seed %d")
:format(#classes, #places, SEED))
local Battles = nil
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if lib then Battles = lib.require("OverworldBattle") end
if not Battles then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
end
local function label(place, class)
local arena = Battles and Battles.arena()
if not arena then
return ("%s / %s -- NO ARENA (plain battle screen)")
:format(place[1], class)
end
return ("%s / %s -- %s arena at cell %d,%d; enemy %d,%d player %d,%d")
:format(place[1], class, arena.shape, arena.x, arena.y,
arena.enemyCell[1], arena.enemyCell[2],
arena.playerCell[1], arena.playerCell[2])
end
for i = 1, 8 do
-- the TRAINERS are the random part; the places are walked in order, so
-- one run covers open route, town, forest, cave and gym floor rather
-- than landing on the same meadow eight times
local place = places[(i - 1) % #places + 1]
local class = classes[love.math.random(#classes)]
local rec = game.data.trainers[class]
local partyIndex = love.math.random(#rec.parties)
local tag = ("%02d_%s_%s"):format(i, place[1]:lower(), class:lower())
U.teleport(game, place[1], place[2], place[3], "down")
-- let the neighbourhood's meshes land before the fight starts, so the
-- first battle frame is not the flat fallback
U.wait(90)
local battle = BattleState.newTrainer(game, class, partyIndex)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe, then the send-out chatter
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
U.log(label(place, class))
U.shot(game, ("%s/%s_1_menu.png"):format(DIR, tag))
-- FIGHT -> first move -> the animation
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(24)
U.shot(game, ("%s/%s_2_anim.png"):format(DIR, tag))
-- and a while later, where the drift has moved the world under them
U.wait(240)
U.shot(game, ("%s/%s_3_drift.png"):format(DIR, tag))
-- out of the battle and on to the next: pop whatever is above the
-- overworld rather than trying to play the fight to its end
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+556 -18
View File
@@ -10,7 +10,12 @@ local T = require("tests.modkit")
local Pipelines = require("src.render.Pipelines")
local Data = T.fixtures.load()
local run = T.sdk.loadMod("mods/DRAMATIC_SHAPE", { data = Data })
-- DS_MOD_PATH lets the suite run against a copy of the mod. The game holds
-- an open handle on the live directory while it is running, and on Windows
-- that is enough to make the headless loader's directory probe fail, so a
-- run alongside a live session points at a copy instead.
local MOD_PATH = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
local run = T.sdk.loadMod(MOD_PATH, { data = Data })
T.eq(#run.errors, 0,
"DRAMATIC_SHAPE loads clean: " .. table.concat(run.errors, "; "))
@@ -44,11 +49,13 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
-- ------- the ladders the engine drives
T.eq(#defs.voxel.levels, 5, "voxel exposes a five-rung ladder")
T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder")
T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
T.eq(defs.voxel.levels[5], "75", "the top rung is the 75-degree camera")
T.eq(Pipelines.maxLevel("voxel"), 4, "the engine reads the ladder height")
T.eq(Pipelines.levelLabel("voxel", 2), "35", "the engine reads the rung labels")
T.eq(defs.voxel.levels[2], "FULL",
"FULL is the first rung after OFF -- the order those two get used in")
T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera")
T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height")
T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
-- ------- gating: inert until switched on, and inert without a GPU
@@ -96,23 +103,118 @@ local byLabel = {}
for _, row in ipairs(rows) do byLabel[row.label] = row end
T.check(byLabel.VOXEL ~= nil, "the VOXEL row is offered")
T.check(byLabel["T-SHIFT"] ~= nil, "the T-SHIFT row is offered")
T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
-- ------- this mod's own settings
--
-- Neither is a pipeline (they parameterise the voxel pass rather than
-- owning one), so they reach the same menu through the ui.options.rows
-- hook, and store themselves where the mod manager's settings page looks.
-- None of them is a pipeline -- two parameterise the voxel pass rather than
-- owning one, and the third decides what a BATTLE is drawn over, which is
-- not a stage the registry has -- so they reach the same menu through the
-- ui.options.rows hook, and store themselves where the mod manager's
-- settings page looks.
local Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- ------- FULL is a preset that owns the other rows
--
-- While it is selected the settings it drives come OFF the menu -- including
-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
-- longer decides anything is worse than no row.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" } })
local fullIds = {}
for _, row in ipairs(fullRows) do fullIds[row.id] = true end
T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
T.check(not fullIds["pipeline:tiltshift"],
"FULL takes T-SHIFT off the menu -- it owns the blur")
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
-- ------- and off FULL, the rows come back, grouped with the mode
--
-- The engine splices a pipeline row in beside TILT and lands a mod's own
-- additions at the END of the list, which would leave this mode's four rows
-- in two places with unrelated rows between them.
Pipelines.setLevel("voxel", 2)
local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" },
{ id = "void_fill" } })
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"], 3,
"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")
-- ------- the open menu notices when FULL is stepped onto or off
--
-- OptionsMenu reads its row list every frame but builds it once, so without
-- a rebuild the rows FULL owns stay on screen until the menu is reopened --
-- and stepping OFF FULL never brings them back.
local OptionsMenu = require("src.ui.OptionsMenu")
local pressed = {}
local menuGame = {
data = Data,
save = { options = { pipelines = {}, modOptions = {} } },
mods = { modOptions = {} },
input = { wasPressed = function(_, k) return pressed[k] or false end },
stack = { pop = function() end },
writeOptions = function() end,
}
Pipelines.setLevel("voxel", 2)
local menu = OptionsMenu.new(menuGame)
local function rowIndex(m, id)
for i, row in ipairs(m.rows) do if row.id == id then return i end end
end
T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"off FULL the menu opens with the mode's settings on it")
-- step the VOXEL row from 15 down to FULL, the way the player would
menu.index = rowIndex(menu, "pipeline:voxel")
pressed = { left = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"and the rows FULL owns left the OPEN menu at once")
T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them")
T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
-- and back off it again
menu.index = rowIndex(menu, "pipeline:voxel")
pressed = { right = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
"and the rows came straight back without reopening the menu")
T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu
Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#hookedRows, 3, "the options hook added a row per setting")
local grid, curve = hookedRows[2], hookedRows[3]
T.eq(#hookedRows, 4, "the options hook added a row per setting")
local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
T.eq(grid.label, "V-GRID", "the grid row carries its label")
T.eq(grid.value(), "OFF", "the grid starts off")
T.eq(curve.label, "V-CURVE", "the curve row carries its label")
T.eq(curve.value(), "OFF", "the curve starts off")
T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label")
T.eq(battles.value(), "ON",
"overworld battles are on by default -- the mode's headline is the world "
.. "in 3D, and a battle is where the player spends half the game")
-- stepping writes through to the one place both rows read
local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
@@ -694,13 +796,14 @@ T.eq(modeColors(nil), nil, "and a pipeline given no paletteFor at all is safe")
-- 5 V-GRID toggle the wireframe
-- 6 T-SHIFT cycle the blur ladder
-- 7 V-CURVE cycle the horizon bend
-- 8 3D-BTL toggle overworld battles
--
-- Only 6 reaches the pipeline registry the documented way. Game:keypressed
-- answers the engine's own display keys first and returns -- 3 is TILT and
-- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 belongs
-- to settings that own no pass and so have no registry to claim from. The
-- mod therefore wraps Game:keypressed, and these pin what that wrapper is
-- allowed to take.
-- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 and 8
-- belong to settings that own no pass and so have no registry to claim
-- from. The mod therefore wraps Game:keypressed, and these pin what that
-- wrapper is allowed to take.
local Game = require("src.core.Game")
Pipelines.reset()
@@ -722,10 +825,36 @@ keyGame = {
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
-- ------- 3 walks the ANGLE rungs and steps over FULL
--
-- The key is a display-mode cycler: it should change the camera and nothing
-- else. FULL reaches in and rewrites four other settings, so landing on it
-- mid-walk would silently turn the blur to maximum and flatten the horizon
-- with nothing on screen saying a keypress had done it.
Pipelines.setLevel("voxel", 0)
local walk = {}
for _ = 1, 6 do
Game.keypressed(keyGame, "3")
walk[#walk + 1] = Pipelines.levelLabel("voxel")
end
T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
"3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL")
-- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to
-- the rung that shows the same camera -- the key never appears to do nothing.
-- Matched by angle, so this follows FULL if it is ever retuned.
T.eq(VoxelState.ANGLES_DEG[VoxelState.FULL_LEVEL + 1], 35,
"FULL is the 35-degree camera")
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Game.keypressed(keyGame, "3")
T.eq(Pipelines.level("voxel"), 1, "3 cycles the voxel camera ladder")
T.eq(Pipelines.levelLabel("voxel"), "50",
"a press from FULL goes to 50, since FULL already IS the 35 camera")
Pipelines.setLevel("voxel", 0)
Game.keypressed(keyGame, "3")
T.eq(Pipelines.level("voxel"), 2, "and keeps climbing it")
T.eq(Pipelines.level("voxel"), 2, "3 cycles the voxel camera ladder")
Game.keypressed(keyGame, "3")
T.eq(Pipelines.level("voxel"), 3, "and keeps climbing it")
Game.keypressed(keyGame, "6")
T.eq(Pipelines.level("tiltshift"), 1, "6 cycles the tilt-shift blur")
@@ -739,6 +868,13 @@ local curveBefore = Curve.setting:get()
Game.keypressed(keyGame, "7")
T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE")
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.setting:get(), true, "3D-BTL starts on")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false, "8 toggles overworld battles off")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and back on")
-- 3 also clears the two engine modes it displaced. Without this a player
-- who left TILT or GBC FX on before enabling the mod has no key left to
-- turn them off with, and both fight the diorama -- TILT is the flat fake
@@ -761,7 +897,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
-- TILT with or without us. Park the ladder on its top rung and turn both
-- back on, so the single press under test is the one that wraps to OFF --
-- where nothing else is going to clear them.
Pipelines.setLevel("voxel", Pipelines.maxLevel("voxel"))
-- 5 is the "75" rung, the last one the key walks before it wraps to OFF
Pipelines.setLevel("voxel", 5)
Tilt.setLevel(3)
GBCFX.setLevel(4)
keyGame.save.options.tilt = 3
@@ -890,6 +1027,407 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3],
Voxel.angle = 0
-- ------- overworld battles: where the fight is staged
--
-- The arena search is pure map arithmetic, so it is driven here against
-- hand-drawn maps rather than a fixture: the shape it looks for, the order
-- it relaxes in, and what it refuses are all things a picture can state
-- exactly.
--
-- The stub answers the small surface BattleArena asks a Map for. `rows` are
-- strings, one per cell row, "." open and anything else solid; "w" is water
-- (open to a surfer only) and "d" a warp tile.
local BattleArena = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArena")
local function stubMap(rows)
local at = function(cx, cy)
local row = rows[cy + 1]
return row and row:sub(cx + 1, cx + 1) or "#"
end
return {
widthCells = #rows[1],
heightCells = #rows,
inBounds = function(_, cx, cy)
return cx >= 0 and cy >= 0 and cx < #rows[1] and cy < #rows
end,
warpAtCell = function() return nil end,
isWarpTileCell = function(_, cx, cy) return at(cx, cy) == "d" end,
isWalkableCell = function(_, cx, cy) return at(cx, cy) == "." end,
isWaterCell = function(_, cx, cy) return at(cx, cy) == "w" end,
isGrassCell = function(_, cx, cy) return at(cx, cy) == "g" end,
}
end
-- a field with room for the wide arena on its right-hand side only
local field = stubMap({
"##########",
"#....#####",
"#....#####",
"#....#####",
"#....#####",
"#....#####",
"#....#####",
"##########",
})
local arena = BattleArena.find(field, 2, 3, false)
T.check(arena ~= nil, "a field with a 3x6 clearing has an arena")
T.eq(arena.shape, "wide", "and it is the wide shape, not the fallback")
T.eq(arena.w, 3, "the wide arena is three cells across")
T.eq(arena.h, 6, "and six deep")
-- the two mons stand three cells apart down the middle column, which is
-- what the picture in BattleArena says and what the camera is built around
T.eq(arena.enemyCell[1], arena.playerCell[1],
"both mons stand in the arena's middle column")
T.eq(arena.playerCell[2] - arena.enemyCell[2], 3,
"with three cells of ground between them")
T.check(arena.enemyCell[2] < arena.playerCell[2],
"the enemy is the NORTH one -- the far end from a camera parked south")
-- every cell of the shape is open, apron included: that one-cell margin is
-- the difference between a staged shot and a fight in a doorway
for cy = arena.y, arena.y + arena.h - 1 do
for cx = arena.x, arena.x + arena.w - 1 do
T.check(field:isWalkableCell(cx, cy),
("arena cell (%d,%d) is open ground"):format(cx, cy))
end
end
-- world-pixel centres, which is the unit everything downstream works in
T.eq(arena.player[1], arena.playerCell[1] * 16 + 8,
"the player's mark is the centre of its cell in world pixels")
T.eq(arena.mid[2], (arena.enemy[2] + arena.player[2]) / 2,
"and the midpoint is halfway between the two")
-- nearest, not first found: two clearings, and the one under the player wins.
-- Wide enough that the battle camera -- which stands a few cells east and
-- south of whatever it is aimed at -- is over real ground for BOTH of them,
-- so this measures proximity rather than the clearance preference below.
local twin = stubMap({
"########################",
"#.##########.###########",
"#.##########.###########",
"#.##########.###########",
"#.##########.###########",
"########################",
"########################",
"########################",
"########################",
"########################",
})
local near = BattleArena.find(twin, 12, 3, false)
T.eq(near.shape, "narrow", "no 3x6 anywhere, so the search relaxes")
T.eq(near.x, 12, "and takes the corridor the player is standing in")
T.eq(BattleArena.find(twin, 1, 3, false).x, 1,
"the same map from the other side picks the other one")
-- the wide shape wins even when a narrow one is closer: it is the shot this
-- mode is framed for, so proximity does not get to overrule it
local both = stubMap({
"#.#########",
"#.####...##",
"#.####...##",
"#.####...##",
"######...##",
"######...##",
"######...##",
})
T.eq(BattleArena.find(both, 1, 1, false).shape, "wide",
"a wide arena across the map beats a narrow one underfoot")
-- water is ground for a surfer and nothing at all for anyone else
local sea = stubMap({
"wwwwww",
"wwwwww",
"wwwwww",
"wwwwww",
"wwwwww",
"wwwwww",
})
T.eq(BattleArena.find(sea, 2, 2, false), nil,
"open water is not open ground to someone walking")
T.check(BattleArena.find(sea, 2, 2, true) ~= nil,
"but it is to a surfer, who is standing on it")
-- tall grass is walkable and is still not a stage: it is knee-high geometry
-- standing between a nearly-level camera and the mon behind it
local meadow = stubMap({
"########",
"#ggg..g#",
"#ggg..g#",
"#ggg..g#",
"#ggg..g#",
"#ggg..g#",
"#ggg..g#",
"########",
})
local mown = BattleArena.find(meadow, 2, 3, false)
T.check(mown ~= nil, "a meadow with a bare strip still has an arena")
for cy = mown.y, mown.y + mown.h - 1 do
for cx = mown.x, mown.x + mown.w - 1 do
T.check(not meadow:isGrassCell(cx, cy),
("no cell of the arena is tall grass (%d,%d)"):format(cx, cy))
end
end
T.eq(BattleArena.find(stubMap({
"#####", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#####",
}), 2, 3, false), nil,
"a map that is nothing but grass has nowhere to stand a fight")
-- a doormat is walkable and is still not a stage
local hall = stubMap({
"######",
"#..d.#",
"#....#",
"#....#",
"#....#",
"######",
})
local halled = BattleArena.find(hall, 2, 3, false)
T.check(halled == nil or halled.shape == "narrow",
"a warp tile is excluded, so the room's only 3x6 does not qualify")
T.eq(BattleArena.find(stubMap({ "###", "###" }), 1, 1, false), nil,
"a map with no room at all yields no arena, and the battle draws plainly")
-- an authored refusal is honoured over the search: a map looked at and found
-- to have nowhere a fight reads has to be able to say so, or the fallback
-- goes and finds one of the spots that were already rejected by eye
local roomy = stubMap({
"#####", "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", "#####",
})
roomy.id = "TEST_REFUSED"
T.check(BattleArena.find(roomy, 2, 3, false) ~= nil,
"a roomy map finds an arena by search when nothing is authored")
BattleArena.setOverride("TEST_REFUSED", false)
T.eq(BattleArena.find(roomy, 2, 3, false), nil,
"an authored false refuses the map outright, search and all")
BattleArena.setOverride("TEST_REFUSED", nil)
T.check(BattleArena.find(roomy, 2, 3, false) ~= nil,
"and dropping the refusal restores the search")
-- ------- overworld battles: the over-the-shoulder framing
--
-- The claim the whole shot rests on. The two pics are PINNED to their cells,
-- so the rig has to put those two patches of ground exactly where the GB's
-- own battle screen puts its two pics -- the player's low and left at
-- (40, 96), the enemy's high and right at (124, 56). Reprojected through the
-- real camera rather than asserted about the constants, so the day someone
-- retunes the rig this either still lands or says so.
local BattleCam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
local BattleScene = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleScene")
local Voxel3Dcam = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
-- where a world point lands in the 160x144 frame, or nil behind the camera
local function project(cam, point, w, h, fov)
local saved = cam.fov
if fov then cam.fov = fov end
Voxel3Dcam.camera = cam
local m = Voxel3Dcam.viewProjection(0, 0, w or 160, h or 144)
Voxel3Dcam.camera = nil
cam.fov = saved
local x = m[1] * point[1] + m[2] * 0 + m[3] * point[2] + m[4]
local y = m[5] * point[1] + m[6] * 0 + m[7] * point[2] + m[8]
local cw = m[13] * point[1] + m[14] * 0 + m[15] * point[2] + m[16]
if cw <= 1e-6 then return nil end
return (x / cw * 0.5 + 0.5) * (w or 160), (y / cw * 0.5 + 0.5) * (h or 144)
end
BattleCam.reset()
local shot = BattleArena.find(field, 2, 3, false)
local rig, pitch = BattleCam.rig(shot, 0)
local px, py = project(rig, shot.player)
local ex, ey = project(rig, shot.enemy)
T.check(px ~= nil and ex ~= nil, "both marks are in front of the camera")
T.check(math.abs(px - 26) < 1 and math.abs(py - 96) < 1,
("the player's cell projects onto its pic's feet at (26, 96): got "
.. "(%.2f, %.2f)"):format(px, py))
T.check(math.abs(ex - 124) < 1 and math.abs(ey - 56) < 1,
("the enemy's cell projects onto its pic's feet at (124, 56): got "
.. "(%.2f, %.2f)"):format(ex, ey))
T.check(px < ex, "which puts the player's mon LEFT of the enemy's")
T.check(py > ey, "and lower in the frame -- nearer the camera")
-- low and long: the eye is near the floor looking almost along it, which is
-- what a 56-pixel sprite standing on a 16-pixel tile costs
T.check(pitch > math.rad(60) and pitch < math.rad(85),
"the rig watches the arena from near ground level")
-- ------- a mon covers its own square
--
-- The pics are drawn at INTEGER scales -- 56 pixels for a front pic at 1x, 64
-- for a back pic at 2x -- so the only way a mon can stand in one overworld
-- square is for the camera to make that square that big. This is the pair of
-- equations the default rig was solved against alongside the two anchors, and
-- it is the one that sets how far away the camera has to be.
local function span(cam, point)
local a = project(cam, { point[1] - 8, point[2] })
local b = project(cam, { point[1] + 8, point[2] })
return math.abs(b - a)
end
T.check(math.abs(span(rig, shot.player) - 64) < 4,
("the player's square is a back pic wide (64px at 2x): got %.2f")
:format(span(rig, shot.player)))
T.check(math.abs(span(rig, shot.enemy) - 56) < 4,
("the enemy's square is a front pic wide (56px at 1x): got %.2f")
:format(span(rig, shot.enemy)))
-- ------- the close rig, for rooms the default cannot stand back from
--
-- Five blocks is further than a gym is wide, so on one the default eye lands
-- outside the map and the border ring crosses the near mon. An arena asks for
-- the short rig by name, and the two things that have to hold are that it
-- really is close enough to sit in a room, and that it frames the SAME shot
-- -- both marks still on their anchors -- so swapping rigs changes the lens
-- and nothing about the composition.
local function eyeDistance(cam)
local dx = cam.eye[1] - cam.focus[1]
local dy = cam.eye[2] - cam.focus[2]
local dz = cam.eye[3] - cam.focus[3]
return math.sqrt(dx * dx + dy * dy + dz * dz)
end
T.check(eyeDistance(rig) > 120,
"the default long lens stands well back -- that is what sizes the mons")
BattleCam.reset()
local snug = { mid = shot.mid, cam = "wide" }
local closeRig = BattleCam.rig(snug, 0)
local cd = eyeDistance(closeRig)
T.check(cd < 80,
("the wide lens is within five cells, so it fits inside a gym: got %.1f")
:format(cd))
T.check(cd < eyeDistance(rig), "and is nearer than the default")
local cpx, cpy = project(closeRig, shot.player)
local cex, cey = project(closeRig, shot.enemy)
T.check(math.abs(cpx - 26) < 1 and math.abs(cpy - 96) < 1,
("the wide lens lands the player's mark on the same anchor: (%.2f, %.2f)")
:format(cpx, cpy))
T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
("and the enemy's too: (%.2f, %.2f)"):format(cex, cey))
T.check(span(closeRig, shot.player) < span(rig, shot.player),
"the mons render smaller on it, which is what it trades for fitting")
-- 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")
T.eq(BattleCam.rigFor({}), BattleCam.RIGS.tele, "and one that asks for nothing")
T.eq(BattleCam.rigFor({ cam = "nonsense" }), BattleCam.RIGS.tele,
"as does one that asks for a rig that does not exist")
-- ------- the pins survive the window
--
-- The scene renders at the WINDOW's resolution, not the GB's, so the rig's
-- field of view is widened by the ratio the window bears to the letterbox.
-- What that has to buy is exactness: the letterbox sub-rectangle of the
-- widened render must be the framing the rig asked for, or the pics come
-- unpinned from the ground by however much it is out.
for _, win in ipairs({ { 1920, 1080, 7 }, { 640, 576, 4 }, { 1280, 1024, 7 },
{ 800, 720, 5 } }) do
local pw, ph, s = win[1], win[2], win[3]
local lx = math.floor((pw - 160 * s) / 2)
local ly = math.floor((ph - 144 * s) / 2)
local wide = BattleScene.letterboxFov(rig.fov, ph, s)
T.check(wide >= rig.fov - 1e-9,
"a window taller than the letterbox needs a wider lens, never a tighter one")
local wx, wy = project(rig, shot.player, pw, ph, wide)
local gx, gy = (wx - lx) / s, (wy - ly) / s
T.check(math.abs(gx - px) < 0.01 and math.abs(gy - py) < 0.01,
("%dx%d at scale %d reproduces the GB framing exactly: (%.3f, %.3f) vs "
.. "(%.3f, %.3f)"):format(pw, ph, s, gx, gy, px, py))
end
-- ------- the drift
--
-- With the pics pinned, the drift is not decoration on a backdrop -- it
-- moves the mons themselves, and the whole reason it reads as depth is that
-- it moves the near one and the far one by DIFFERENT amounts. A backdrop
-- that merely slid would move them by the same one.
BattleCam.update(BattleCam.PAN_PERIOD / 4)
local rig2 = BattleCam.rig(shot, 0)
local px2 = project(rig2, shot.player)
local ex2 = project(rig2, shot.enemy)
T.check(math.abs(px2 - px) > 0.5, "the drift moves the near mark")
T.check((px2 - px) * (ex2 - ex) < 0,
"and the far one the OTHER WAY -- parallax about a point between them")
T.check(math.abs(px2 - px) < 8 and math.abs(ex2 - ex) < 8,
"neither is flung across the frame: the mons drift, they do not travel")
-- very slow: a quarter of the cycle is several seconds, and what it moves in
-- one FRAME has to be imperceptible
BattleCam.reset()
BattleCam.update(1 / 60)
local slow = BattleCam.rig(shot, 0)
local sx = project(slow, shot.player)
T.check(math.abs(sx - px) < 0.2,
"one frame of drift moves a mon by a fifth of a pixel")
-- a placed camera declines the world curve outright: the bend exists to
-- drop the horizon away from a walking player, and here it would tip the
-- arena floor out from under the two mons pinned to it
T.eq(rig.curve, 0, "the battle camera switches the world curve off")
-- ------- the hit flash belongs to the mons, not the screen
--
-- The engine draws it as a full-screen white rectangle, which is a flash on
-- a white battle field and a whiteout of the map, the HUD and the text box
-- over a world. It is dropped on the way past and put back on the two cards.
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.flashing(nil), false, "no battle, no flash")
T.eq(Battles.flashing({ fx = {}, frame = 0 }), false,
"a battle with no flash counter is not flashing")
T.eq(Battles.flashing({ fx = { flash = 0 }, frame = 0 }), false,
"nor one whose counter has run out")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 0 }), true,
"a live counter flashes on the frames the engine would")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 2 }), false,
"and is dark on the others, which is what makes it flicker")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 5 }), true,
"on a four-frame cycle")
-- ------- the wireframe is forced on in a battle
--
-- A fight is a staged shot rather than the world being walked through, so it
-- always wears the seams. The player's own V-GRID row must not be touched by
-- that -- an override, not a write, or switching the mode off mid-battle
-- would quietly rewrite a setting they chose.
local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
Grid.override = nil
local rowWas = Grid.setting:get()
T.eq(Grid.enabled(), rowWas and true or false,
"with no override the wireframe follows the row")
Grid.override = true
T.eq(Grid.enabled(), true, "an override forces it on")
T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone")
Grid.override = false
T.eq(Grid.enabled(), false, "an override can force it off too")
Grid.override = nil
T.eq(Grid.enabled(), rowWas and true or false,
"and clearing it hands the answer back to the row")
-- ------- the depth of field is measured off the two marks
--
-- The slab held sharp is the one the mons are standing in, so the band has
-- to be derived from where they landed rather than from a constant -- and it
-- has to hold BOTH, which a band narrower than the gap between them would
-- not.
local BattleDOF = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleDOF")
local focusY, band, range = BattleDOF.bandFor(96, 56, 144)
T.check(math.abs(focusY - 76 / 144) < 1e-9,
"the band centres between the two marks")
T.check(focusY - band < 56 / 144 and focusY + band > 96 / 144,
"and is wide enough that both of them are inside it")
T.check(range > 0, "with a ramp out of it, so the band edge has no seam")
local wide = select(2, BattleDOF.bandFor(120, 30, 144))
T.check(wide > band, "marks further apart hold a deeper slab in focus")
Pipelines.reset()
run.release()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Assemble the per-map battle-arena screenshots into contact sheets.
One shot per map is a lot of files to open one at a time, so this lays them
out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which
is enough to judge a whole region at a glance and spot the one that is wrong.
python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \
.scratchpad/areas .scratchpad/sheets [order.txt]
Reads every PNG in the source directory and writes sheet_01.png,
sheet_02.png ... into the destination.
The optional third argument is a file of map ids, one per line, which both
FILTERS and ORDERS the sheets. That is what makes them worth looking at: the
authored list in data/battle_arenas.lua is grouped by region, so feeding its
keys through puts the routes together and the caves together, instead of
alphabetical order interleaving a gym with a tower floor. Without it every
PNG in the directory is used, sorted by name.
"""
import os
import sys
from PIL import Image, ImageDraw, ImageFont
PER_SHEET = 8
COLS, ROWS = 2, 4
TILE_W = 960 # each shot is 3840 wide; a quarter of that
LABEL_H = 34
PAD = 8
BG = (18, 18, 20)
LABEL_BG = (32, 32, 36)
LABEL_FG = (232, 232, 236)
def font(size):
for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def tile(path, w, h, label, f):
"""One labelled cell: the shot scaled to fit, with its map id under it."""
cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG)
try:
shot = Image.open(path).convert("RGB")
except OSError as err:
d = ImageDraw.Draw(cell)
d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f)
return cell
shot.thumbnail((w, h), Image.LANCZOS)
cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2))
d = ImageDraw.Draw(cell)
d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG)
d.text((10, h + 7), label, fill=LABEL_FG, font=f)
return cell
def wanted(src, order_path):
"""The PNGs to lay out, in the order to lay them out in."""
have = {n.lower(): n for n in os.listdir(src)
if n.lower().endswith(".png")}
if not order_path:
return sorted(have.values()), []
names, missing = [], []
with open(order_path, encoding="utf-8") as fh:
for line in fh:
key = line.strip()
if not key or key.startswith("#"):
continue
hit = have.get(key.lower() + ".png")
if hit:
names.append(hit)
else:
missing.append(key)
return names, missing
def main(src, dst, order_path=None):
names, missing = wanted(src, order_path)
if not names:
sys.exit("no PNGs to lay out from " + src)
# said out loud rather than silently skipped: a sheet set that quietly
# dropped a map would read as "every area is covered" when it is not
for key in missing:
print("MISSING no shot for %s" % key)
os.makedirs(dst, exist_ok=True)
probe = Image.open(os.path.join(src, names[0]))
tile_h = round(TILE_W * probe.height / probe.width)
f = font(24)
sheet_w = COLS * TILE_W + (COLS + 1) * PAD
sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD
made = []
for page, start in enumerate(range(0, len(names), PER_SHEET), 1):
batch = names[start:start + PER_SHEET]
sheet = Image.new("RGB", (sheet_w, sheet_h), BG)
for i, name in enumerate(batch):
cell = tile(os.path.join(src, name), TILE_W, tile_h,
os.path.splitext(name)[0].upper(), f)
col, row = i % COLS, i // COLS
x = PAD + col * (TILE_W + PAD)
y = PAD + row * (tile_h + LABEL_H + PAD)
sheet.paste(cell, (x, y))
out = os.path.join(dst, "sheet_%02d.png" % page)
sheet.save(out, optimize=True)
made.append((out, [os.path.splitext(n)[0] for n in batch]))
print("%s %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b)))
print("\n%d shots -> %d sheets" % (len(names), len(made)))
if __name__ == "__main__":
if len(sys.argv) not in (3, 4):
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2],
sys.argv[3] if len(sys.argv) == 4 else None)