mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 07:50:53 +02:00
@@ -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
|
||||
|
||||
+84
-1
@@ -1,6 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 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 50-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.
|
||||
|
||||
### 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 `75`. `FULL` is already the 50-degree camera,
|
||||
so stepping to the rung of that name would look like the key had done
|
||||
nothing.
|
||||
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,430 @@
|
||||
-- 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.
|
||||
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.
|
||||
for _, card in ipairs(monCards(arena, groundY, textures)) do
|
||||
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
||||
BattleBillboard.PULL)
|
||||
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
|
||||
@@ -0,0 +1,683 @@
|
||||
-- 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 and 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
|
||||
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
|
||||
|
||||
-- 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
|
||||
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
|
||||
|
||||
-- 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
|
||||
+66
-6
@@ -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
|
||||
@@ -658,6 +712,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+51
-2
@@ -23,10 +23,59 @@
|
||||
|
||||
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 50 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, 50, 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 50
|
||||
-- degrees, so a press from it goes to 75 rather than back to 50, and the key
|
||||
-- never appears to do nothing.
|
||||
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
|
||||
|
||||
@@ -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.2.0"
|
||||
-- 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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.0.6",
|
||||
"version": "1.2.0",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+535
-18
@@ -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,33 @@ 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 50 degrees, so a press from it goes ON to 75 rather than back to
|
||||
-- the rung that shows the same camera -- the key never appears to do nothing
|
||||
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"), "75",
|
||||
"a press from FULL goes to 75, since FULL already IS the 50 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 +865,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 +894,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 +1024,389 @@ 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 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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user