Compare commits

...

65 Commits

Author SHA1 Message Date
DramaticShape 1e08f09c27 a few fixes 2026-08-01 14:51:55 -04:00
DramaticShape c9d7e26858 first ledge commit 2026-08-01 12:04:51 -04:00
DramaticShape eb231d221e Merge pull request #46 from DramaticShape/water-reflections
Add fancy water
2026-08-01 00:32:30 -04:00
DramaticShape 980383bb92 update battle water 2026-08-01 00:28:54 -04:00
DramaticShape 22b58e27a4 fix android water shading 2026-08-01 00:13:01 -04:00
DramaticShape 98f7419b72 fix water for android glsl shaders 2026-07-31 23:39:05 -04:00
DramaticShape 92fef2a37e update for modkit update 2026-07-31 23:29:39 -04:00
DramaticShape 8f38aeb36e water updates 2026-07-31 23:05:54 -04:00
DramaticShape 9a9441899a first pass at water 2026-07-31 22:54:16 -04:00
DramaticShape 7f76caa5f6 Merge pull request #35 from DramaticShape/mobile-dpi-fix
bump version
2026-07-31 14:19:02 -04:00
DramaticShape be2f0464c5 Merge branch 'mobile-dpi-fix' of https://github.com/DramaticShape/DramaticShapeVoxelMod into mobile-dpi-fix 2026-07-31 14:18:25 -04:00
DramaticShape 8728783b22 account for dpi issues on updated 3d battles 2026-07-31 14:18:02 -04:00
DramaticShape 731ecd9677 Merge pull request #34 from DramaticShape/mobile-dpi-fix
Mobile dpi fix
2026-07-31 14:10:24 -04:00
DramaticShape 851f36d46f Merge pull request #33 from DramaticShape/master
rebase
2026-07-31 14:04:17 -04:00
DramaticShape 775757b2d6 account for dpi issues on updated 3d battles 2026-07-31 13:51:54 -04:00
DramaticShape 20f1807edd Merge pull request #25 from DramaticShape/back-sprite-battles
add day/night filter to 2d
2026-07-30 22:37:52 -04:00
DramaticShape 4da8e5dc3e add day/night filter to 2d 2026-07-30 22:36:59 -04:00
DramaticShape 3eb62a5e00 Merge pull request #24 from DramaticShape/back-sprite-battles
Back sprite battles
2026-07-30 22:05:14 -04:00
DramaticShape 26d1d96d52 hide tilt/gbcfx 2026-07-30 21:57:29 -04:00
DramaticShape d9a000d7ad add back sprite option for 3d battles 2026-07-30 21:52:54 -04:00
DramaticShape f12b564dcd moved route 1 battle location again, added transparency to bottom battle menu 2026-07-30 21:34:25 -04:00
DramaticShape d1a1c69c7d Merge pull request #16 from DramaticShape/android-night-cycle-fix
iterate version to 1.2.1
2026-07-30 00:11:29 -04:00
DramaticShape 500556c3fc iterate version to 1.2.1 2026-07-30 00:11:02 -04:00
DramaticShape e621c28a74 Merge pull request #15 from DramaticShape/android-night-cycle-fix
android fix for day night cycle issue
2026-07-30 00:09:22 -04:00
DramaticShape eabc8af716 android fix for day night cycle issue 2026-07-30 00:04:52 -04:00
DramaticShape 785838e6cd Merge pull request #14 from DramaticShape/3d-battle-mod
fix issue with fight transition in oak's lab
2026-07-29 23:14:58 -04:00
DramaticShape 14f844ad8e fix issue with fight transition in oak's lab 2026-07-29 23:13:36 -04:00
DramaticShape cb325af6cf Merge pull request #13 from DramaticShape/3d-battle-mod
3d battle mod
2026-07-29 22:55:21 -04:00
DramaticShape 50f8f6bfe3 Merge branch '3d-battle-mod' of https://github.com/DramaticShape/DramaticShapeVoxelMod into 3d-battle-mod
# Conflicts:
#	README.md
2026-07-29 22:54:22 -04:00
DramaticShape 284172db7b add clock sync option 2026-07-29 22:50:54 -04:00
DramaticShape 6d27d91411 fix glinting on windows and striping 2026-07-29 22:43:20 -04:00
DramaticShape e898cedad6 Merge pull request #12 from DramaticShape/3d-battle-mod
1.2.0 updates
2026-07-29 22:31:17 -04:00
DramaticShape f2a18b62b2 Merge branch 'master' into 3d-battle-mod 2026-07-29 22:30:51 -04:00
DramaticShape 5ff56eab46 bump version 2026-07-29 22:29:23 -04:00
DramaticShape 99e18f7b5b set viridian to night time lighting 2026-07-29 22:27:25 -04:00
DramaticShape 9c263f0937 glass masking 2026-07-29 22:23:21 -04:00
DramaticShape 7e3245a9ef more shadow fixes, fix tree borders 2026-07-29 22:23:08 -04:00
DramaticShape 18857e039a fixed disconnected shadows 2026-07-29 21:51:42 -04:00
DramaticShape f841378330 add day night cycle 2026-07-29 21:42:34 -04:00
DramaticShape 6d7a93dbad remove voxel grid from overworld sprites 2026-07-29 20:09:18 -04:00
DramaticShape e14953c78a mitigate diagonal shadow artifacts 2026-07-29 20:06:18 -04:00
DramaticShape 8dc046f72e add skybox 2026-07-29 19:53:12 -04:00
DramaticShape c3ba10e17a added battle transition into overworld 2026-07-29 19:09:48 -04:00
DramaticShape 41a86b5fb2 second pass at indigo plateau 2026-07-29 19:03:11 -04:00
DramaticShape 9a34ba9c77 force 3d battle ui, initial victory road updates 2026-07-29 18:45:00 -04:00
DramaticShape 097bdcd14b fix poke mart back wall 2026-07-29 18:16:29 -04:00
DramaticShape e838ff4558 made cash register a sprite 2026-07-29 18:01:47 -04:00
DramaticShape 8e7a80055d fix couch man 2026-07-29 17:36:03 -04:00
DramaticShape 5ea70a278f update route one battle location 2026-07-29 16:22:53 -04:00
DramaticShape 60d5362c89 fix couch man 2026-07-29 15:44:52 -04:00
DramaticShape 3c1781f6b1 fix vertical line in battle sprites 2026-07-29 15:35:28 -04:00
DramaticShape b21fd46ea7 Remove battle staging details from README
Removed section about battle staging and conditions.
2026-07-28 18:49:07 -04:00
DramaticShape 917fbac38c Merge pull request #7 from DramaticShape/3d-battle-mod
fix white screen flash on shake on versions > 1.3.0
2026-07-28 16:16:45 -04:00
DramaticShape 0f303e7975 bump required version to 0.1.32 2026-07-28 16:14:43 -04:00
DramaticShape d8d1d7e336 fix white screen flash on shake on versions > 1.3.0 2026-07-28 16:10:58 -04:00
DramaticShape 4e6aa30c2c Merge pull request #6 from DramaticShape/3d-battle-mod
fixed full screen flashing in battles
2026-07-28 15:22:35 -04:00
DramaticShape 2e0ae37bcd fixed full screen flashing in battles 2026-07-28 15:22:13 -04:00
DramaticShape 9207712b19 Merge pull request #5 from DramaticShape/3d-battle-mod
set FULL tilt to 35
2026-07-28 14:01:54 -04:00
DramaticShape 2447046aee set FULL tilt to 35 2026-07-28 14:00:03 -04:00
DramaticShape 458a1d7bf9 Merge pull request #4 from DramaticShape/3d-battle-mod
3d battle mod
2026-07-28 13:38:39 -04:00
DramaticShape 5781c28b60 added FULL menu option for voxel mod 2026-07-28 13:37:19 -04:00
DramaticShape 6280cdffe7 Added overworld battles 2026-07-28 13:01:38 -04:00
DramaticShape acc66b51bb Merge pull request #3 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:19:36 -04:00
DramaticShape 894a4b4a55 Merge pull request #2 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 11:13:57 -04:00
DramaticShape 2a4269b20e Merge pull request #1 from DramaticShape/20260726_various_fixes
20260726 various fixes
2026-07-27 04:58:07 -04:00
50 changed files with 15245 additions and 451 deletions
+200
View File
@@ -0,0 +1,200 @@
name: Release
# Packs the mod into an installable .zip and publishes it as a GitHub Release,
# once per push to main.
#
# Archive layout: every mod file at the archive root, manifest.json included.
# That is one of the two shapes the game accepts on MODS > Import mod .zip
# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a
# single top-level folder). Nothing else is added, so the archive stays
# installable by hand too.
#
# Versioning, first rule that applies wins:
# 1. the "version" input of a manual run,
# 2. "[release X.Y.Z]" anywhere in the commit message,
# 3. manifest.json's own version, when it is ahead of every existing tag,
# so bumping the manifest is the normal way to cut a release,
# 4. otherwise the newest vX.Y.Z tag with its patch incremented
# (0.2.99 rolls over to 0.3.0).
# Whichever wins is written into the manifest.json inside the archive, so a
# shipped mod never reports a different version than the release it came from.
#
# Generated by: python3 tools/modkit.py add-release-workflow <mod-id>
# MOD_ID below is stamped to this mod's id when the file is copied.
on:
push:
branches: [master]
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve."
required: false
default: ""
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: ver
env:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, re, subprocess, sys
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def sh(*args):
return subprocess.run(args, capture_output=True, text=True).stdout.strip()
def parse(text):
m = SEMVER.match(text)
return tuple(int(p) for p in m.groups()) if m else None
def die(msg):
print(f"::error::{msg}", file=sys.stderr)
raise SystemExit(1)
with open("manifest.json", encoding="utf-8") as fh:
manifest_version = str(json.load(fh).get("version", ""))
released = sorted(
v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v
)
latest = released[-1] if released else None
override = os.environ.get("DISPATCH_VERSION", "").strip()
if not override:
found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B"))
override = found.group(1) if found else ""
manifest_ver = parse(manifest_version)
if override:
version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)")
source = "the override"
elif manifest_ver and (latest is None or manifest_ver > latest):
version = manifest_ver
source = "manifest.json"
elif latest:
major, minor, patch = latest
patch += 1
if patch > 99:
minor, patch = minor + 1, 0
version = (major, minor, patch)
source = "a patch bump on v%d.%d.%d" % latest
else:
die(f"manifest.json version {manifest_version!r} is not X.Y.Z "
"and there is no vX.Y.Z tag to count from")
text = "%d.%d.%d" % version
print(f"Releasing {text}, from {source}.", file=sys.stderr)
print(f"version={text}")
print(f"tag=v{text}")
PY
- name: Refuse to clobber an existing release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::error::Tag $TAG already exists. Pick a different version."
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::Release $TAG already exists. Pick a different version."
exit 1
fi
- name: Build the mod .zip
env:
VERSION: ${{ steps.ver.outputs.version }}
MOD_ID: "DRAMATIC_SHAPE"
run: |
set -euo pipefail
staging="$RUNNER_TEMP/pkg"
out="$GITHUB_WORKSPACE/dist"
rm -rf "$staging" "$out"
mkdir -p "$staging" "$out"
git archive HEAD | tar -x -C "$staging"
rm -rf "$staging/.github" "$staging/.gitattributes" \
"$staging/.gitignore" "$staging/.luarc.json"
python3 - "$staging/manifest.json" "$VERSION" <<'PY'
import json, sys
path, version = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
manifest = json.load(fh)
manifest["version"] = version
with open(path, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
zip_path="$out/${MOD_ID}-${VERSION}.zip"
(cd "$staging" && zip -qr "$zip_path" .)
unzip -l "$zip_path"
unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json"
python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY'
import json, sys
path, expected = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
version = json.load(fh)["version"]
if version != expected:
raise SystemExit(f"::error::packed manifest says {version}, expected {expected}")
print(f"manifest.json is at the archive root and reports {version}")
PY
(cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt)
cat "$out/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
TAG: ${{ steps.ver.outputs.tag }}
MOD_ID: "DRAMATIC_SHAPE"
run: |
set -euo pipefail
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
range="${prev:+${prev}..}$GITHUB_SHA"
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.'
if [ -n "$changes" ]; then
notes+=$'\n\n## Changes\n\n'"$changes"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$VERSION" \
--notes "$notes" \
"dist/${MOD_ID}-${VERSION}.zip" \
"dist/sha256sums.txt"
echo "Published release $TAG"
+3
View File
@@ -4,6 +4,8 @@
# The SDK suite and the probes it grew out of. A shipped test that requires
# an engine module reads as a private require against the archive
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
tests/arena_pick.lua
tests/battle_shots.lua
tests/dramatic_shape_test.lua
tests/voxel_anim_probe.lua
tests/voxel_door_probe.lua
@@ -19,6 +21,7 @@ tests/voxel_void_probe.lua
# ROM, read the local cache, and emit assets/voxels/*.lua; the carved models
# are what a player installs, the carving is not.
tools/build_voxels.py
tools/contact_sheets.py
tools/building_images.py
tools/building_voxels.py
tools/voxel-survey.md
+863
View File
@@ -4,6 +4,869 @@
### Changed
- **Ledges are cliffs now.** A ledge used to be taken at face value: a 6px
speed bump extruded out of a flat world. But the drawing is the game
telling you about terrain -- the side you hop FROM is higher ground --
so the world now has real elevation: everything above a hop-down edge
stands one ledge-height (6px) up, the lip sits flush with the plateau it
rims, and its south face is the cliff drop wearing the same cropped lip
art it always wore. Stack ledges and the tiers stack; Pallet Town is the
sea-level datum at 0, and the terrain tops out 22px up.
The subtlety is that ledges do not enclose anything -- every one can be
walked around through a gap, so a plateau flood fill would leak through
it, meet itself across its own ledge line and conclude an area sits a
tier above itself. So lib/Elevation.lua closes the lines before it
fills anything. Ledge cells group into runs, and each run is extended
along its own axis from either end -- across the gap the player detours
through -- until it reaches something that closes it: unwalkable
ground, another ledge, or the edge of the world. A run that finds
nothing within reach is not a cliff anybody walks around and stays
open. The cells that extension crosses become STEPS, and they are the
one piece of sloped ground in the world: their tiles grade into 4px and
2px treads, so walking a sealed gap climbs three crisp 2px risers where
the cliff line crosses it.
With every line closed, an ordinary flood over the walkable ground cuts
the world into areas -- and now they really are the areas enclosed by
ledges. Those areas are then levelled against each other in whole
tiers, by least squares over the area graph rather than by propagation:
each lip votes "the area behind me is one tier over the area in front",
areas meeting across ordinary trees or water cast a small
same-ground vote, and a stray lip is outvoted by the run it disagrees
with instead of tipping half a route. Because each area is a single
variable the result is exactly FLAT -- these are plateaus, not a
smoothed field. The solve is global and anchored on Pallet's own
ground, so two connected maps never disagree about a seam; it runs once
inside the build budget, in well under a second for all 43k cells of
Kanto, and cuts them into 199 areas rising six tiers from Pallet at 0 to
the Route 22/23 highlands at 42px.
Roughly three quarters of the ledges with standable ground on both
sides come out as an exact one-tier drop. The rest -- and the ones
whose high side is impassable mountain rock, where there is no plateau
to stand on at all -- take the level of the ground around them, which
reads as terrain rather than as a mistake, but they are the cases still
worth an eye in-game.
Everything that stands on the ground rides it: characters and NPCs
(including ghosts on neighbour maps), grass tufts, flowers, props,
buildings (each on one flattened pad -- no terraced floorboards), tree
hulls, battle arenas and their camera rig, cast shadows (the sun
frustum grows by the tallest base), and the free-roam camera's focus,
which eases after the player's ground height instead of pinning to the
old flat plane. Cliff skirts fall out of the mesher's own
neighbour-difference rule, banded in cell-local height so every crop
the flat world drew is byte-identical there. Interiors and any map not
connected to Pallet keep the classic flat reading, ledge bumps and all.
### Added
- **WATER, a new row on hotkey 9: water reflects the world, the sky, the sun
and the moon.** Every lake, sea and pond in Kanto was a flat animated
texture lying in a hole in the ground. It is now a surface, and it is
reflective.
What it reflects, in the order the shader resolves them:
- **The sky.** The reflected direction goes through the very matrix the
frame is drawn with, as a point at infinity, and the canvas row that
lands on is looked up on Sky's own band ramp -- the identical texture,
the identical checkerboard dither, the identical display-mode transform.
So the sky in the lake is the sky over it, and the two meet at the
waterline with no seam at any pitch, field of view, window shape or zoom.
Blue at noon, gold at dusk, navy under the moon; GRAY gets a grey lake
and CLASSIC a green one, for nothing.
- **The sun and the moon**, hung by ANGLE rather than by screen position,
because a reflected body is usually off the top of the frame entirely
and a projected point stops meaning anything out there. The angular
radius is the painted disc's own radius run back through the camera's
field of view, so the two are the same size -- craters, dithered rim,
the sunset's loom and all, off one shared list. This is also the
specular: a low sun lays a broken gold path across the water on its own,
out of the reflection rather than out of a highlight term nailed on
beside it.
- **The world, in screen space.** The reflected ray is walked forward in
world space, each step projected through the same matrix, looking for
where it passes behind what the depth buffer holds -- then binary-refined
onto the contact and read out of a copy of the frame as it stood before
the water went down. Shore trees, buildings, ledges and cliffs land in
the water because they are on screen; where the ray leaves the frame or
finds nothing, the sky above answers instead, which is what makes the far
half of a lake sky and the near half scenery with no seam between them.
Fresnel decides how much of it shows: almost nothing looked straight down
at, almost everything looked along -- so the 15-degree rung is a pond and
the 75-degree rung is a mirror, off the same surface.
Every rung gets one, though, which took a lean. A reflection off flat water
points as far above the horizon as the eye is above the water: 15 degrees
at the top rung -- grazing the sky's pale end, sweeping the sun's own path,
travelling far enough across the screen for the march to find the shoreline
-- and 75 degrees, straight up, at the steepest. Up there the bands are at
their darkest, the sun and moon sit at about 6 degrees of squashed
elevation and are nowhere near it, and the screen-space ray leaves the top
of the frame in two steps. All three are correct, and together they are a
lake with nothing in it.
So the reflection now LEANS toward the elevation the top rung reflects at,
by however far the camera is from having a horizon in frame -- **zero** at
the rung where the horizon IS in frame, so the one place the join can be
seen, the waterline, is still the exact reflection it was. Toward an
elevation rather than by a weight, because the ray it starts from differs
at every rung and a fixed fraction lands them all somewhere different: the
middle rungs came out further from the sun than the steepest one. And it
leans the LEVEL reflection with each column's own deflection added back on
top -- leaning the perturbed ray sets its elevation outright, which at full
lean gave every column on the lake the same one, flattened the sky to a
single band and removed the moon entirely.
Three rungs rather than a toggle. FULL is the whole thing; SKY drops the
ray march and keeps the sky, sun and moon, which is most of the look for a
handful of instructions; OFF is the flat water this mode always drew. The
FULL preset sets it to FULL.
- **The water surface is a field of pixel-tall columns, and they are real.**
Not a normal map: a heightfield of one-world-pixel bars -- the same unit
every other voxel in this mode is built from, and exactly one texel of the
water tile -- each standing a WHOLE number of pixels high and rising and
falling on its own.
Three travelling wave trains, and one of them dominates: a wave has a
DIRECTION, and its crest is a line running across it for as far as the
water goes. Three trains of equal weight cancel and reinforce in patches
instead, and the surface comes out as round islands of raised pixels with
no travel to them -- blobs rather than waves. The dominant train's
wavelength is about forty world pixels, five tiles, so a crest is a long
run of columns at one height with a step down either side.
Drawn with no extra geometry at all: the mesh is still one flat quad per
tile, and the columns are found by walking the view ray down through the
slab in the pixel shader. That is what makes them read as solid -- a tall
bar hides the shorter ones behind it, you see the SIDE of the ones facing
you (wearing the mesh's own direction shading, so a crest is lit like every
other voxel in the world), and the whole field parallaxes against the plane
as the camera moves. The water's art is read at the column the ray landed
on rather than at the flat quad underneath, so the pixels travel with the
bars they are made of.
The columns are what you SEE; the normal they reflect with is read off the
smooth surface they are a quantisation of. That distinction is the whole
difference between a moon on the water and confetti: whole-pixel heights
have whole-pixel differences, so a normal built from them can only point in
about five directions, and a sun or moon barely two degrees across falls
between them. Still one normal per column, so the surface stays
pixel-quantised in space while the value it reflects with is continuous.
Crests stand up to five world pixels, well past the 2px recess water sits
in -- deliberately, because the columns are relief drawn inside the water
quad's own footprint, so a bar that reaches above the bank is clipped at
the water's edge rather than spilling over it. What it buys is a surface
with real swell in it instead of a two-rung terrace.
And it moves in STEPS, at **15 a second** -- the cadence hand-drawn pixel
art is animated at. A surface built out of whole pixels that crawls
smoothly between them gives away that the quantisation is only skin deep.
Each step advances the dominant wave by exactly one world pixel, derived
from that train's own wavelength rather than tuned beside it, so nothing
ever lands half-way between two pixels and changing a wavelength moves the
speed with it.
### Changed
- **The water surface is its own mesh, and its own pass.** A mirror cannot be
drawn until what it reflects exists, so water is lifted out of the terrain
mesh at build time and drawn between the world and the characters. The
shoreline faces around it are untouched -- they belong to the GROUND that
exposes them -- and the sun still sees the surface, so a tree at the water's
edge still throws its shadow onto the lake.
- **The scene's depth buffer is a readable canvas.** It was an internal buffer
that could be written and tested and never sampled; it is now the same
buffer with a texture handle on it, at the same cost. Drivers that will not
make one fall straight back to the old buffer and lose the reflections and
nothing else.
- **The cast is reflected too -- by being drawn twice.** Gen 1 draws people
over the world and water is world, so a surfing player has to composite
OVER the water they are sitting on, which puts them after it; and a
reflection can only hold what came before it. So the walkers, the NPCs and
the authored figures are painted into the reflection COPY alone, where they
are in the picture the water reflects and not yet in the picture the water
is drawn into. Both draws go through one function, so they cannot come out
different. The staged battle does the same with its two Pokemon.
The ray march finds them the honest way round: a sprite is not in the depth
buffer at that point, so a ray aimed at one passes through to the terrain
standing behind it and reads the copy there -- where the sprite is already
painted. The reflection lands a hair off the sprite's own depth and exactly
on its colour, which at a lake's worth of wave is the same picture.
### Changed
- **The waves arrive in sets now, and a little slower.** Three fixed trains
are an exactly periodic field -- every forty-odd pixels of sea wore the
same crest at the same height, which reads as wallpaper the moment a lake
is bigger than the repeat. Two long-wavelength fields now ride the
dominant train, four to five carrier wavelengths apiece so neither reads
as a wave itself: a SWELL that breathes its amplitude, so a few tall
crests march through and hand over to a lull that is itself moving, and a
BEND that bows its phase, so a crest line curves across the surface
instead of ruling itself over all of it. The two lesser trains stay
plain: they are texture rather than structure, and a third modulator is
the soup the train weights exist to avoid. The step beat comes down from
15 to 12 a second -- the crests were hurrying, and a big wave is slower
than a walk cycle -- still a clean divisor of the engine's 60, and still
exactly one world pixel of dominant-crest travel per step.
- **Staged battles draw their water plain, whatever the WATER row says.**
The reflective pass is tuned for the overworld's ladder of cameras; a
battle's camera is PLACED -- low, tilted, framed like a picture -- and
under it the pass read wrong: Fresnel opened all the way up, the leaned
sky landed on bands the framing never shows, and a lake-sized arena came
out as murk wearing the tile art. The battle is a stage set, and stage
water is painted: the flat animated tiles the mode always drew, with the
mons compositing over them like everything else on the set.
### Fixed
- **On Android the water stayed flat, as if the row were off -- and once it
did draw, it came up in blocks with the haze showing through the holes.**
Three separate faults, every one of them invisible on desktop GL, run down
on a Galaxy Z Fold 7 with the driver's own compiler errors in logcat:
**The shader would not build.** Fragment floats default to **mediump** on
GLSL ES while the vertex stage's default is highp, and the water shader is
the mod's first to declare the same uniform -- the frame's `vp` matrix --
in BOTH stages, one on each default; GLSL ES refuses to link that, and the
pass fell back, quietly and by design, to the flat water the mode always
drew. The pixel stage now lifts its float default to highp (guarded, so a
GPU without fragment highp still compiles and falls back flat), which
settles the link and is also simply needed: the march works in world
coordinates that run to a few thousand, where fp16 has no fraction left.
The world-position varying is qualified highp for the same reason the
wireframe's always was, and the depth sampler too -- samplers default to
**lowp** whatever the floats are set to, and eight bits of depth is a
march with nothing to land on. One wrinkle inside the fix: LOVE's header
forward-declares `effect()` under ITS default, and Samsung's Xclipse
compiler treats a definition whose parameter precisions have drifted from
the prototype's as an illegal overload -- so effect()'s own float
parameters stay pinned to mediump, matching the declaration, and the
maths above them runs highp regardless.
**The depth test read the wrong texels.** The shader's own depth test
normalised LOVE's pixel coordinate by the `screen` uniform, which counts
canvas UNITS -- and on a highdpi phone (Android's density here is 2.625)
a canvas holds that many PIXELS per unit, so the lookup ran to 2.6,
clamped, and read edge texels across two thirds of the frame. Water
discarded itself in blocks wherever the mis-read depth landed in front,
and the haze backdrop showed through the holes. The coordinate is now
normalised by `love_ScreenSize.xy` -- the bound canvas's own pixel size,
measured in the same units on every display.
**And the readable depth canvas** -- the one hardware requirement the
rest of the mode does not already have -- now tries four formats before
giving up: depth24, depth24 riding a stencil (a pairing some mobile
drivers will texture when they refuse the bare format), depth32f, and
depth16 as the floor every GLES3 device can read. Refused all four, the
reflections are lost and nothing else, exactly as before.
### Known
- Screen-space reflections can only reflect what is in the frame. A tree just
off the top edge is not in the water below it, and a reflection whose ray
runs off the side of the screen fades into the sky rather than ending on a
hard line.
## 1.3.1
### Fixed
- **A staged battle on a phone stood some Pokémon three times the size of the
square they were on.** A Pidgey towered over the arena while the mon beside
it was the right size, which reads as a bug in one species and is not one.
Putting the paper back inside a battle pic (BattlePics, 1.3.0) needs the
pic's pixels, and a LOVE Image does not hand them back -- so the pic is drawn
into a canvas of its own size and the canvas is read. `newCanvas` takes the
SURFACE's dpi scale when it is not told otherwise, `conf.lua` turns highdpi
on for Android and iOS, and Android's display density is routinely 2.75. So
`newCanvas(56, 56)` allocated a 154x154 texture there, the pic was magnified
into it, and the readback came back at the magnified size. The rebuilt pic
was 2.75x the artwork, the engine's pics layer drew it 1:1 because it trusts
`getWidth()`, and the mon stood on its tile nearly three times too big.
Only a pic with an enclosed hole in it is rebuilt at all -- the rest are
handed straight back untouched -- which is why it hit some species and not
others, and why it never showed on desktop, where the dpi scale is already 1.
The readback now asks for one texel per pic pixel, the way the engine's own
`PixelCanvas` does for the same reason. The animated-tile atlas readback took
the same fix: on a phone it would have come back magnified too, and every
tile coordinate in it counts in eights from the top-left.
## 1.3.0
### Added
- **BACK SPRITES, a new row under 3D-BTL: your own Pokémon stays on the battle menu.**
The staged shot stands both mons on the map, which is the mode's whole claim
-- and it costs the framing Gen 1 is most recognisable by: your own Pokémon,
seen from behind, sitting on top of the battle menu with its feet on the box.
With BACK SPRITES on the foe is still geometry standing on its own tile at the far
end of the arena, and the player's side goes back to being the GB's own flat
back pic in the GB's own slot: same art, same 2x, same feet on row 96. It is
the engine's own pics layer that draws it, through the `onlySide` argument
that layer already takes, so every pic effect -- the grow-out-of-the-ball,
the faint slide, the damage blink, the send-out trainer pic -- comes along
unchanged and none of it is reimplemented.
Nothing else about the shot moves. The arena, the camera and the drift are
solved exactly as they were, so the foe stands where it always stood and the
player's cell is simply empty ground in the foreground. Two things follow the
setting: the `pokemon.sprite` hook stops asking for the front pic on the
player's side (it is a back view again, and the front art would be that mon
turned round to face the player it belongs to), and the move-animation offset
drops that side's contribution, because a pic that has not moved cannot have
moved the pair's centre.
OFF by default -- what the mode advertises is the two of them out there --
and only on the OPTIONS menu while 3D-BTL is on, since with staged battles
off the engine already draws exactly this.
### Fixed
- **Battle pics were see-through, and it took a back sprite on a tiled floor
to make it obvious.** Gen 1 pics are two-bit art whose lightest shade is
white, and the decoded PNGs key that shade to alpha 0 -- which cost nothing
when the field behind them was white too. Over a route, every belly, every
eye white and every highlight is a hole with the world showing through, and
the mon reads as a stencil.
`BattlePics` exists to put that paper back and, as written, put none of it
back. It flood-filled the outside from the border and filled what the flood
could not reach, which is exact and, on this game's art, empty: a Gen 1
figure is an open drawing, and its belly walks out to the border through the
gap between its legs. Read across all 305 of the game's battle pics, that
rule finds an enclosed hole in exactly none of them.
The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
OWN BOUNDING BOX, and at three of them -- left, right and top. The bottom is
closed, because it is not a side the background is behind, it is where the
drawing was CUT. A pic is bottom-aligned in its slot with all the margin at
the top, so a mon's lowest row is the last row it was given and everything
below the belly simply stops. Treat that cut as open and the background
pours up inside the figure, which is the channel of world that used to show
through a Clefairy.
That is exact rather than a heuristic: nothing is filled because of what
surrounds it, only because the background provably cannot reach it. Which is
why it needs no idea whether it is holding a front pic or a back one -- the
sky between a pair of ears reaches the top edge and stays sky, the gap
between a body and a raised tail reaches the side and stays gap, the belly
reaches neither and is paper. The silhouette is untouched, so the mon still
cuts cleanly against the world.
It replaces the border flood outright rather than sitting beside it, since
anything the border could not reach the box edges cannot reach either.
The bottom edge needs one more distinction, because two different things
meet the underside of a figure. A DRAIN is where the drawing ran out -- a
belly whose white carries on down until the artist stopped, leaking out
through the inch between a body and a leg -- and is sealed. A MOUTH is the
space between two legs, background that happens to be enclosed on three
sides, and is left open so the world shows through a trainer's stride.
Width tells them apart, and on this game's art it is not a close call.
Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
(Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
Nothing lands between 4 and 10, so the cut is taken at 6 with room either
side rather than tuned to one sprite. Apart from that number the rule stays
exact.
Front pics come back untouched, and not by being special-cased: they are
near-solid silhouettes with almost nothing inside them to fill, so their own
shape is what says so.
Both mons were affected -- the cards in the arena as much as anything -- so
this lands wherever a battle pic is drawn over the world, not just under
BACK SPRITES.
- **The pinned back pic was lit at noon while the world behind it was not.**
Everything standing in the arena goes through the voxel shader, and that
shader multiplies by the hour's tint, so at dusk the diorama warms and at
night it goes blue -- the two mons' cards included, because they are drawn
in the same pass as the ground they stand on. A back pic pinned to the menu
is not in that pass; it is a flat blit over the finished shot, and it stayed
bright over a midnight route.
The same tint is now applied to that one draw, by multiplying every colour
the pics layer sets on its way past -- so the alpha, the faint slide's fade
and the damage blink all compose with it instead of being overwritten. What
it does not get is the sun: the cards are shadow-mapped and a pic pinned to
the menu has no position in the scene to be shadowed at, so it carries the
hour and not the weather.
### Added
- **The hour reaches the FLAT world too, not just the diorama.** DAYTIME drove
the 3D pass through the voxel shader's own tint uniform -- a uniform the 2D
tile path never runs -- so with VOXEL off, the same evening that fell on the
diorama left the flat world at permanent noon. One clock, two worlds, one of
them ignoring it. Outdoor maps now get the same multiply, painted as one
rectangle over the composited world.
The whole difficulty is WHERE, and it is worth writing down. Not on the world
canvas: in a colorized mode that canvas is grayscale art and the blit that
puts it on screen runs it through the palette shader, which classifies each
pixel into a shade BY ITS RED CHANNEL -- multiply a night blue over it first
and every pixel lands in the wrong bucket, so the world does not darken, it
changes colour. Not over the finished frame either, or the dialog boxes and
menus darken along with the world they are held up in front of, which is the
same reason the tilt-shift blur is a `worldPresent` and not a `present`.
Which leaves the instant between the world blit and the UI blit, and the
engine has no seam there -- `worldPresent` only runs when a PIPELINE produced
the world, which in flat mode is precisely what did not happen. So
`Renderer:endFrame` is wrapped and the UI canvas's own draw is watched for:
`blit` passes the canvas it is compositing as the first argument, so the
first draw of `Renderer.canvas` IS the boundary, by identity rather than by
counting. The shader and scissor that call arrives under belong to the UI
blit already in progress, so both are put aside for the rectangle and handed
straight back.
Skipped entirely when a pipeline drew the frame (it tinted itself, and twice
is wrong), indoors (a room has no sky to take its light from), and at midday
(a multiply by white) -- so a game with the clock at DAY issues not one extra
call.
### Changed
- **FULL no longer takes the two battle rows off the menu.** It still owns the
rows that describe the LOOK -- the wireframe, the horizon bend, the blur, the
hour -- because it is a preset for the diorama and a row that no longer
decides anything is worse than no row. 3D-BTL and BACK SPRITES are not that:
one decides what a fight is drawn OVER and the other how it is framed.
FULL still SETS both on arrival; it does not hold them, and leaving them
reachable is the difference between a preset and a lock.
This makes `stagedBattles()` honest as a side effect. It used to answer yes
under FULL as well, on the grounds that FULL owned the 3D-BTL row and
switched it on -- safe only while the row was hidden. With the row reachable
from inside FULL, that clause would have claimed staged battles for a preset
the player had just switched them off inside, pinning BATTLE LAYOUT to OG for
a fight that never gets staged. The row is the only thing that decides now,
which is what `OverworldBattle.begin` and `wantsFront` already believed.
- **TILT and GBC FX are off the OPTIONS menu entirely while this mod is
installed.** Both fight the diorama and both were already half-taken: the
mode's own key forces them off on every press, and the registry switches
TILT off whenever a world pipeline takes the pass. What was left was two
rows a player could set and watch get reverted -- TILT being the flat fake
of what this mode does for real, and GBC FX a full-screen present pass over
the top of the whole thing.
Dropped AND held at zero, which is the part that matters: hiding a live
setting is a trap, because a save written before the mod was installed can
carry TILT 3 and a row that is not there cannot turn it back off. Pinned
wherever the value could arrive from -- the menu opening, a save being
loaded or begun -- so there is no route by which either is on and
unreachable. Uninstalling the mod puts both rows back, at whatever they were
last set to.
- **The battle's text box and menus are frosted glass, like the HUDs.** The
HUD blocks got panels because black glyphs on grass are not readable. The box
at the bottom had the opposite problem and the same cause: it is drawn as an
opaque white slab with a black border, which was the field's own colour back
when the field was white and is a sheet of paper laid over the bottom third
of the diorama now that it is not.
It gets exactly what the HUDs get -- the world behind it, blurred to frosted
glass and laid back down translucent, at the same frost and the same tint --
and it is measured into the same brightness verdict, so the ink over the menu
flips white with the ink over the HUDs rather than against it. Only the FILL
is taken away: the border, the text, the cursor and the down arrow are the
engine's own glyphs in their own places. The move menu's TYPE/PP box and
Mimic's copy menu get their own panels, trimmed to the rows above the box
below them so no pixel is frosted twice.
## 1.2.1
### Fixed
- **On Android the sky went black below its first couple of bands.** A hard-edged
band of black ran from partway down the gradient to the horizon point, with the
moon still hanging correctly inside it. Desktop was unaffected.
What gave it away is that the same colour reached the screen by two routes and
only one of them was wrong. The haze filling the void UNDER the horizon is the
sky's palest band, and it is delivered by `love.graphics.clear` -- it landed
correctly. The bottom of the sky above it is that same band delivered by the
shader, and it was black. So the palette was not reaching the fragment shader,
and nothing was wrong with the palette, the layout or the camera.
The bands went in as `uniform vec3 bands[8]`, filled from Lua and read through
a loop counter, and on Android's GLSL ES the tail of that array arrived as
zero -- which is black. The likeliest reason is the fragment uniform budget:
ES 2.0 only guarantees sixteen uniform VECTORS, and eight band slots plus the
twilight glow plus LOVE's own built-ins is over it. A driver that truncates a
partly-filled array, or one that reflects `bands[0]` and nothing after it,
fails identically -- so the fix removes the whole class rather than the one
cause.
The bands are a one-texel-per-band TEXTURE now, sampled nearest, with the
band index clamped against the ramp's width. One texture unit replaces eight
uniform vectors, there is no array to index and no budget to overrun, and a
sample past the last band lands on the last band instead of on nothing. It is
still a palette and not a picture -- one texel per band on a single row -- so
the sky is still computed per pixel at the size it is displayed at, with
nothing resampled and nothing baked.
Also gone with it: `clamp(x, 0.0, 0.999999)`, which rounds its bound to 1.0 at
mediump -- the fragment default on GLSL ES -- and would have indexed one past
the last band on the sky's bottom row for the same black result.
## 1.1.1
### Fixed
- A move that shakes the screen no longer whites out the frame. The zone pass
fills each zone with its blank colour before drawing the shifted copy -- the
hardware showing empty BG in the strip the shake vacated -- and a shake
program alternates offset and no-offset frames, so over the map that read as
the whole battle screen, menu box included, flashing white a few times a
second. The fill is dropped while a battle is staged on the map; the shake
itself still moves the HUD.
## 1.2.0
### Added
- **A gradient sky behind the diorama, on every `VOXEL` rung.** The void behind
the world used to be a black plate at every rung but the top, where it became
one flat blue -- enough while that void was a sliver, and a wall of paint once
the horizon came into frame.
It is the 8-bit skybox recipe now: four blues painted as flat horizontal bands,
deepest overhead and palest at the bottom, with a CHECKERBOARD of the next band
dithered into the bottom 40% of each one. Alternating two colours on a pixel
grid is how a machine with four to a palette got a fifth, sixth and seventh out
of them, and it is what keeps four bands reading as a gradient rather than as
four stripes. Every channel of the palette is a multiple of 8 -- where a
five-bit GBC channel lands -- so no colour in it is one the hardware could not
have shown. No clouds, nothing moving.
Where the bands END is the camera's own answer. At `75` the ground plane's
vanishing line is genuinely in frame -- projected through the same matrix the
geometry is drawn with -- and the pale end meets it. At the steeper rungs that
line is above the top edge, and what shows up there is the ground running OUT
past the map edge instead, so the bands take a fixed slice of the frame and the
haze fills the rest. One sky across the whole ladder either way.
**Nothing is resampled**, which is why it is drawn the way it is: no baked
160x144 image scaled up to the window, no downsized buffer blown back up, no
texture at all. One rectangle through a shader answers every pixel from its own
canvas coordinate, so a pixel of sky is computed at the size it is displayed
at and there is nothing for a filter to soften. The band edges and the dither
cells are measured in the pass's own pixels-per-world-pixel, handed in fresh
every frame -- so a `ZOOM` keypress is reflected in the frame that follows it,
with nothing cached at the old scale, and the sky's grid is the same grid the
world's own texels sit on.
The palette goes through the display-mode transform like every other palette in
this mod, so GRAY gets four greys and CLASSIC four greens. Below the bands the
void is filled with the palest of them -- which is also what the bottom band
ends on -- so the join has no seam, and a driver that cannot compile the shader
gets flat bands and a logged line rather than a wrong sky.
The overworld only. A battle is a staged shot whose placed camera has the
horizon above the frame, so the arena keeps exactly the flat sky it had.
- **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`,
`DUSK`, `DAWN`, `SYNC`, `CYCLE`. One twenty-minute clock underneath all of
them -- ten minutes of sun, ten of moon -- where the four named settings
are PINS on that dial (noon, mid-night, sunset, sunrise), `CYCLE` lets it
run, picking up from whichever pin or SYNC sky the player was just looking
at, and `SYNC` -- the DEFAULT -- lays the machine's own clock onto the
dial: local noon is the DAY pin, midnight is NIGHT, six and eighteen the
twilights, an hour of the real day is fifty seconds of dial. Everything is
a pure function of the clock, so the pinned DUSK is exactly the running
cycle stopped at sunset. While **VOXEL** sits on `FULL` the DAYTIME row is
HELD at `SYNC` and taken off the menu with the other rows the preset owns
(DayNight.forceSync, enforced from the preset, the rows hook and the
manager's options_changed -- the same three places BATTLE LAYOUT's pin
lives): the full diorama runs on the real sky.
**The sun and the moon are in the sky**, and their positions are honest:
the disc is the light's own direction projected through the same matrix the
geometry is drawn with, so it stands over the point on the horizon its
shadows point away from, at every pitch, window shape and zoom. The sun's
noon is this mod's existing sun to the digit -- southeast, 45 degrees up,
overhead behind the north-facing camera and correctly out of frame -- and
its arc swings north at both ends, so the disc stands IN frame through dawn
and dusk, rising half-set on the horizon. The moon arcs the northern sky
all night, due north (screen centre) at mid-night, with scaled crater
cells. Both are cell art on the sky's own dither grid, sized by the frame
(a celestial body's apparent size is an angle, so zooming the ground does
not swell it), and both are SCISSORED to the sky's region: the horizon
point is where a setting body disappears -- it never hangs under the map.
**The sky follows the clock.** Phase palettes -- the daytime blues,
gold-to-violet dawn, a hotter gold-to-indigo dusk, moonlit navy -- six
bands each now, blended along the dial and re-quantised onto the 5-bit
lattice, so every mixed frame is still a colour the hardware could show.
The blends bend through designed WAYPOINTS rather than straight across --
a golden hour on the way into dusk, a violet civil twilight either side of
the night -- because day's blue and dusk's gold are near-complements, and
a straight lerp between complements bottoms out in dishwater grey. Through the twilights a posterised, checker-dithered GLOW
warms the bands around the low sun -- painted light, not an airbrush. The
blends are 75 seconds wide either side of each twilight and the pins land
on their phase palette unmixed.
**The shadows follow the sun and the moon.** The shear every shadow is
thrown by (direction opposite the body's bearing, length its elevation's
cotangent, clamped at twice the caster's height) comes off the clock, the
shadow map's signature carries it, and the light's press fades out over the
last twelve degrees before the horizon -- so sunset hands off to moonrise
through a soft shadowless gap, and moonlight presses at about two-thirds
the sun's weight. The scene shader also multiplies every surface by the
hour's tint: neutral at noon, warm at the twilights, dim blue at night.
**Outdoors only**, by the same `Map.isOutdoor` test the sky already rests
on: indoors keeps the noon rig, the neutral tint and no sky -- a cave at
midnight is exactly as dark as a cave at noon. Viridian Forest is the case
between, a CANOPY map (DayNight.CANOPY): there is no sky to paint and no
sun to see, so the shadow rig stays the mod's fixed noon light -- all that
ever filtered through the leaves -- but night still FALLS in a forest, so
of everything the clock does, exactly one thing reaches it: the hour's
tint, in free-roam and staged battles alike. A battle staged on an
outdoor map fights under the hour: the night sky behind the arena, the
tint on the mons, the sunset taking the arena's shadows with it; an indoor
arena is untouched. The engine's own `world.tod` hook is answered
(`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
period ride this clock for free.
**The clock rides the save slot.** On the engine's `save.writing` event the
cycle's time is written into the mod's own save-file bucket
(`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A
save with no clock in it starts at day.
- **Window glass.** The panes in the overworld art -- the framed squares on
building fronts, the small lights in doors -- are found by SHAPE in the
tileset image (a black border row, four or five black-flanked glass rows,
a closing border), at pixel granularity because the door's pane straddles
a 2x2 tile block. No tile ids are hardcoded: a conversion that draws its
own windows in the same idiom gets glass for free. The scan yields a mask
texture aligned to the tileset atlas, which the scene shader samples with
the same coordinates the terrain does -- so the effect lands on any wall,
at any angle, in free-roam and staged battles alike, with no geometry
work.
By day a thin glint crosses the panes WHILE THE VIEW MOVES: the sweep's
phase is fed by the camera's own travel and its strength fades out within
a beat of standing still -- a reflection is something the viewpoint does,
so still camera means still glass. The sweep pattern lives in the pane's
OWN texels, not the screen's: a screen-anchored pattern has the world
sliding through it at zoom speed whenever the camera pans, which strobed
(worst walking against the sweep); anchored to the glass, panning moves
nothing and a step advances the glint a fraction of a texel, the same in
every direction. It lifts the texels toward sky-white and leaves the
shine art visible through it. The mask is consulted only by meshes
textured from the tileset atlas (Voxel3D.glass), never by sprite sheets,
whose coordinates would land on the panes' atlas positions by accident.
After dark the panes are LIT: the texel's own pattern carried into a warm
lamp colour, replacing the shaded answer entirely -- a lit window ignores
the sun, every shadow and the hour's tint, exactly as a window with a lamp
behind it does. The lamps follow the clock (DayNight.windowLight): on
through dusk, full all night, mostly out by dawn, and never lit indoors.
- **A fade out of a battle, where there used to be a hard cut.** The engine
wipes INTO a fight with one of the original's eight transitions and cuts
straight out of it: `BattleState:finish` pops itself and the map is simply
there on the next frame. Between a white field and a tile map the original got
away with that; between a placed camera looking across an arena and a diorama
looking down on a walking player it reads as a glitch. The battle now fades to
black, closes behind it, and the map fades up out of it -- twelve frames each
way, registered as a `voxel_battle_exit` transitions record so the timing is
retunable in data like the wipes it answers.
Only while voxel mode is on, and then for EVERY battle, including one that
found no arena and drew on the flat battle screen: what is being smoothed over
is the return to the map, and the map is a diorama either way. With the mode
off, the vanilla cut is untouched.
One black rectangle over the FINISHED composite does the fading, so the world,
the letterbox bars and the battle's own text box all darken by the same amount
-- the renderer's existing warp-fade overlay is painted between the world and
the UI, which would have left the text box bright over the black. A blackout's
own warp fade or an evolution prompt still owns the way out when it takes the
screen: the fade stops at the cut rather than fading in over the top of it.
- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
puts the whole mode in its intended state -- the 35-degree camera, the
miniature blur at maximum, the horizon flat, the view fitted, and battles
on the map -- rather than making a player assemble it from four rows.
While it is selected, every row it owns comes OFF the menu: V-GRID,
V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is
worse than no row. Stepping onto or off `FULL` rebuilds the open menu in
place, so the rows leave and return under the cursor instead of waiting
for the menu to be reopened.
It applies its settings when the row ARRIVES at `FULL`, not every frame:
holding them would make the zoom keys and the wheel dead while it was on.
Leaving it deliberately undoes nothing -- reverting would discard whatever
had been changed since.
### Fixed
- **The hit flash whited out the whole screen.** The engine draws it as a
full-screen white rectangle, which is a flash on a white battle field and
a whiteout of the map, the HUD and the text box over a world. It is now
dropped on the way past and put back where it was ever about: the two
Pokemon go solid white for those frames, silhouette and all, and nothing
else in the frame moves.
- **A scripted battle cut straight in with no transition** (an ENGINE seam,
fixed in `src/script/Commands.lua` rather than in this mod): the rival in
Oak's lab, and every `start_battle` script, pushed the BattleState bare --
no flash, no wipe, the theme starting late -- where the original wipes
into scripted fights like any other. `start_battle` now routes through the
overworld's own `pushBattle`, which is also the path this mod wraps, so a
scripted fight gets its arena staged and the cast culled BEFORE the wipe
instead of catching up behind it. A battle scripted with no overworld
under it still starts bare, and no music plays twice (BattleState's own
start is a same-song no-op).
- **A standing figure's shadow detached from its feet under a low sun.** The
shadow compare forgives `slack` world pixels so lit ground does not acne
against its own texels, and that same forgiveness lit the first `slack` of
every cast shadow -- so the shadow started a bias-width away from the feet,
further the lower the sun reached (the classic peter-panning, invisible at
the old fixed 45 degrees and plain at a day/night golden hour or under the
moon). Sprite cards -- characters, authored figures, flowers, battle mons
-- are now drawn into the shadow map snugged TOWARD the sun along their
own ray (`ShadowMap.snug`): moving along the ray changes nothing about
where a shadow falls, but storing the card shallower takes three quarters
of the forgiveness back for the shadow it throws -- and for nothing else:
no terrain moved, so the acne margin is untouched where it matters. The
obligation that comes with it: every snugged caster's LIT draw hands the
same snugged transform to its own shadow lookup (Voxel3D.draw's
`sunModel`), so stored and lookup agree exactly and the compare keeps its
full margin -- read un-snugged, the missing nine tenths showed up as
diagonal moire bands crawling across every sprite. The shadow root lands
back under the feet at every hour.
### Changed
- **Under `VOID FILL: TREES` the border wall is modelled trees or nothing.**
Only the first block past the map body gets carved into round trunks and
canopies; the two blocks past that were too far out to be worth the quads,
so they fell through to the mesher's plain box and came out as a flat-topped
slab of tree ART sitting beside the modelled forest -- a painted-on plateau,
and the more obvious the lower the camera got. Rather than pay to carve
hulls nobody walks near, the wall now simply STOPS where the carving does:
`Structures` does not build the ring past that distance (the same "nothing
out there" `BLACK` already produces), and the mesher drops any cell inside
it the 2x2 canopy grouping could not claim, so no strip of boxes survives at
a corner. `WATER` and every indoor border are untouched -- a flat sheet of
water is what water looks like from above anyway.
- **The two HP boxes snap to the window's edges during a staged battle.** The
battle screen is 160x144 in the middle of the window and the world is the
whole of it, which left both HUD blocks huddled together in the middle of the
frame with map showing on either side of them -- a Game Boy screenshot pasted
over a diorama rather than the diorama's own furniture. The foe's block now
sits against the left edge and the player's against the right, on the same
frosted glass, with the same tiles at the same size on the same rows. The
pokeball rows and the safari ball count travel with the block whose rows they
share. On a window shaped like the GB screen there is nowhere to go and
nothing moves.
The engine draws them into the 160x144 canvas, which clips at its own edges,
so the layer is rendered to a texture and composited into the world image --
the one surface here that covers the whole window. A driver that cannot do
that falls back to the HUD in the frame rather than to no HUD.
- **`BATTLE LAYOUT` is pinned to `OG` while battles are staged on the map**, and
the row comes off the OPTIONS menu with the rows `FULL` owns. The staged shot
is composed in the GB's own frame -- the arena camera is solved to put a cell
under each pic's feet, and the HUD rects and the intercepted background fill
are measured there too -- and `WIDE` re-lays that screen out on a 304x144
surface, moving every one of them. Set rather than worked around, on every
route in: the options row, hotkey `8`, the mod manager's page, `FULL`'s
preset, and a save that arrived with `WIDE` already on. Switching `3D-BTL`
off hands the row back with `WIDE` selectable again.
- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
a display-mode cycler -- it should change the camera and nothing else --
and `FULL` reaches in and rewrites four other settings. Landing on it
mid-walk would silently push the blur to maximum and flatten the horizon
with nothing on screen saying a keypress had done it. `FULL` stays on the
OPTIONS row, where a preset that changes other rows belongs.
A press FROM `FULL` goes to `50`. `FULL` is already the 35-degree camera,
so stepping to the rung of that name would look like the key had done
nothing. Matched by angle, so it follows `FULL` if that is ever retuned.
- **The mode's four options are one block in the menu.** The engine splices
a pipeline row in beside TILT and lands a mod's own rows at the end of the
list, which had these four in two places with unrelated engine rows
between them. The settings now follow the pipeline rows directly.
## 1.1.0
### Added
- **Battles happen on the map you were standing on.** The battle screen's
white field is replaced by the world: the mod finds the nearest patch of
open ground, points a placed over-the-shoulder camera at it, and draws the
fight over that. New **3D-BTL** row and hotkey `8`, on by default.
The arena is a 3x6 clearing of cells the player could walk on, with the
two mons three cells apart down the middle column and a one-cell apron all
round so the camera looks across floor rather than into a wall. Where no
map has room for that -- a corridor, a cave, a shop -- the search relaxes
to a 1x4 corridor with the apron given up, and where even that will not
fit the battle draws exactly as it always did.
Everything else in the frame is the engine's own. The mon pics, HUDs, HP
bars, move animations, faint slides and text box are drawn by BattleState,
in its order, at its coordinates -- the GB's own layout, with the player's
mon low and left and the enemy's high and right, which is why the camera
is placed east of the arena axis rather than the layout being moved to
suit the camera. What changes is what is behind them.
Three things carry the shot. The overworld's cast is culled before the
wipe, so it plays over an empty map and no bystander is standing in the
arena. The camera drifts on a slow orbit about a point between the two
mons, which moves the near ground and the far ground by different amounts
-- parallax, not a sliding backdrop. And a depth-of-field pass holds the
band of frame the two mons stand in sharp and softens the middle distance
and the foreground; both mons are in focus by construction, because they
are drawn as the battle screen's own pics after the pass has run.
**Nobody moves.** The arena is where the CAMERA goes. Nothing here writes
a cell, a facing, a flag or a warp, so a trainer's post-battle dialogue is
still talking to someone standing in front of them, and the blackout path,
sight lines and every script find the player exactly where they left them.
The two HUD blocks gain the backing the white field used to be. Gen 1
draws them as black glyphs straight onto the background with no box round
them, and black-on-grass is not readable; the backing is painted inside
`drawHUDs`, so it lands in the same target and takes the same zone colour
as the HUD it sits under, in both the colorized and flat pipelines.
Declines cleanly at every step it cannot take: no depth support, no open
ground, the row switched off, or a terrain mesh still building all end at
the battle screen the engine has always drawn.
### Changed
- **Characters are flat sprite billboards, and nothing about a sprite is
voxelized any more.** Every figure -- the player, NPCs, the ghosts
standing on a neighbour map -- is now its current 2D frame on a single
+45 -6
View File
@@ -9,10 +9,34 @@ 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.
Water is a surface rather than a texture lying in a hole. It is a field of
one-pixel-wide voxel columns, each standing a whole number of pixels tall and
rising and falling as waves — found by walking the view ray through them in
the shader, so a crest hides what is behind it and shows you its lit side,
with no extra geometry anywhere.
And it reflects. The sky it stands under, in the same bands, the same dither
and off the same clock, so the lake and the sky above it meet at the
waterline with no seam. The sun or moon hanging in it, at the size the
painted disc is drawn, craters and all. Whoever is standing beside it —
walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a
screen-space ray march adds the rest of what is on screen: the shoreline, the
trees behind it, the buildings across the bay. How much of it shows is
Fresnel, so the top rung is a mirror and a looking-straight-down rung is a
pond, off the same water.
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,8 +49,23 @@ 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 |
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
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
are still on the OPTIONS menu. Pressing `3` also switches both off — they
fight the diorama, and it is the way back from having left one on.
**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.
Two of the engine's own rows are taken away while this mod is installed:
**TILT**, which is the flat fake of what this mode does for real, and **GBC
FX**, a full-screen present pass over the top of the diorama. Both are held at
off rather than merely hidden — a row that is not there cannot switch off a
value an older save arrived with. Uninstall and both come back, at whatever
they were last set to.
Everything the battle screen draws as a box — the two HUD blocks, the text
box and the menus over it — sits on frosted glass rather than on the white
field it used to have behind it: the world underneath, blurred and laid back
down translucent, with the ink flipping white where the ground it lands on is
dark. Nothing the engine draws inside a box moves; only the paper is gone.
+196
View File
@@ -0,0 +1,196 @@
-- 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
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
-- wide shape only fits in the western connection border, which staged every
-- fight at the edge of the world instead of on the road.
--
-- Of the seventeen spots the route has outside that border, fourteen are
-- this one mid-route clearing and the other three bury the near mon behind
-- a hedge -- which the clearance test passes, since it measures terrain
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
["ROUTE_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" },
}
+446 -197
View File
@@ -70,6 +70,21 @@
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- And `prop_bg` (not a class either): which GB shades count as BACKGROUND
-- when a pinned per-pixel prop is cut out, for the drawings whose own body
-- reaches the edge of their bounding box and so vote themselves away.
-- Keyed by tile, because two props sharing an atlas can want opposite
-- answers on the same shade.
--
-- And it may carry `figures` (not a class either): hand-drawn pixel masks
-- that lift a FIGURE PAINTED INTO furniture off it and stand it up as a
-- standee, leaving the furniture its own geometry. A pin resolves a
-- whole 8x8 tile, so it can never separate two things that share one --
-- and nothing automatic can either when the figure has no background
-- margin to flood from and wears the same shades as what it sits on.
-- The Pokemon Center's seated man is the case; see POKECENTER below for
-- the format.
--
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
-- and no single class covers that. They live in the `buildings` list at
@@ -83,10 +98,17 @@ return {
ground = 0,
water = -2,
void = 0,
-- doubles as the terrain TIER: on the connected overworld the ground
-- above a hop-down edge stands this many pixels up (lib/Elevation.lua)
-- and the lip's box sinks by the same amount to sit flush as its rim,
-- so retuning it retunes the cliffs with the faces that clothe them
ledge = 6,
fence = 10,
sign = 12,
wall = 16,
-- masonry drawn two courses tall (the Indigo Plateau's rim, the
-- badge-check gates): as tall as a statue on its plinth
cliff = 32,
tree = 16,
roof = 28,
bed = 7,
@@ -694,22 +716,17 @@ return {
-- bodies so they stand ON them) and the PC (66/70/82/86), which
-- stands on its pinned desk
billboard = { 58, 59, 66, 70, 74, 75, 82, 86 },
-- Why the man on the couch rides the top face instead of standing
-- up. He is drawn across two tile rows (skin pixels appear in
-- rows 8 and 9 and stop dead at the row 9/10 seam), and folding
-- two rows upright requires both to share a class, which makes the
-- box two tiles deep -- and a fully folded box repeats its north
-- row across its whole top face. So every upright arrangement
-- puts his head on screen two or three times: as a 16px seat-back
-- his head lands on the front AND twice on the top; as a 32px
-- bookcase he becomes a cabinet taller than the room's walls.
-- Dropping the seat to floor level to clear his face does not help
-- either, it only moves which copy you see. He also cannot be a
-- standee: the drawing has no floor margin, so all three non-black
-- shades touch the cluster rim and the mask drains 307 of its 420
-- interior pixels -- 46% of him even segmented alone, because his
-- skin is the same light shade as the couch behind him. Riding
-- the top face is the one arrangement that draws him exactly once.
-- The man on the couch, cut out by hand and stood up (see `figures`
-- below). Nothing automatic reaches him. A class pin resolves a
-- whole 8x8 tile and he SHARES his tiles with the couch, so pinning
-- them stands the furniture up with him; riding the couch's top
-- face (what this did before) draws him lying flat on the cushion;
-- and he cannot be segmented into a standee either, because the
-- drawing has no background margin for a flood to enter by and his
-- skin is the same light shade as the couch -- the mask drained 307
-- of his 420 interior pixels, 46% of him, even segmented alone.
-- An authored mask is the only thing that can tell a man from the
-- sofa he is painted into, so that is what `figures` carries.
-- the PC's desk body, which the PC stands on
table = { 9, 88 },
-- the potted plants: bush (32/33/48/49) over pot (34/35/50/51,
@@ -721,6 +738,82 @@ return {
-- them; the standee reads darker than the flat art, which is the
-- accepted trade for a real plant silhouette
prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
-- ...but the POTS were draining outright. The plant pair is drawn
-- as one 4x4-tile block and the pot's olive base is flush on that
-- block's bottom row, so the background vote -- which reads the
-- shades touching the drawing's own bounding box -- came back with
-- "dark" in it, and every dark pixel in the plant left with the
-- floor. The pots rendered as hollow black frames around one or
-- two surviving pixels while the flat art has solid olive bodies.
--
-- So name the background outright for these eight tiles: the floor
-- IS light and white here, and nothing else is. That restores 152
-- pixels of the block (55% -> 69% of it kept) and cannot move any
-- other prop, which matters -- the same call tileset-wide would pull
-- the dark wall band into the healing consoles' screens and strip
-- three pixels off the PC, because those two want the opposite
-- answer on the very same shades.
prop_bg = {
{ tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
shades = { "light", "white" } },
},
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and
-- spills out of it in BOTH directions, which is why he needs three
-- tile columns:
--
-- 36 / 52 the couch's west arm. 36's two RIGHTMOST columns are
-- the back of his head; 52 is the plain arm, and is also
-- what 36 wears once his hair comes off it
-- 37 / 53 him: head, then face and body
-- 57 / 60 the floor east of the couch, which his hair and his
-- foot overhang. 1 and 26 are those same two floor
-- tiles as the artist drew them WITHOUT him -- 8 and 5
-- pixels apart respectively -- so lifting him off is
-- lossless there
--
-- `pixels` is the hand-drawn line between the man and the furniture;
-- `under` is what each tile wears once he is lifted off it. The
-- couch keeps its polygonal box and he stands on top of it.
--
-- The mask keeps ALL of 37/53's column 7, the couch's east edge.
-- Where that column is drawn dark it is the couch's own rule -- but
-- it is also where his hair crosses the tile seam, so dropping it
-- floats the overhang free of his head. And where it is drawn WHITE
-- it is the right side of his face, so dropping it opens a slit down
-- his cheek (rows 5-8, which is exactly what the first cut did).
-- Keeping the rule twice costs nothing: tile 39 redraws it on the
-- couch beneath him either way. Repainting 36 as 52 does cost one
-- row -- 36's top trim band, which 52 does not carry -- and that is
-- the accepted price for not leaving a second copy of his head lying
-- on the arm. The background corners around his head and the
-- cushion wedge under his legs are the only pixels given back.
figures = {
{
w = 3,
tiles = { 36, 37, 57,
52, 53, 60 },
under = { 52, 39, 1,
52, 39, 26 },
pixels = {
"..........XXXXX.........",
"........XXXXXXXX........",
".......XXXXXXXXXX.......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"........XXXXXXXXX.......",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXXX.......",
".........XXXXXXXXX......",
"...........XXXXXX.......",
"..............XX........",
},
},
},
},
-- The Poke Marts (one 4x4 layout serves every city; the tileset
@@ -731,12 +824,61 @@ return {
-- boxed the two free-standing shelf racks into one 4-tile-deep
-- monolith.
MART = {
-- the wall band stays one 16px face: the trim and case tops
-- (40/90/91), the SALE cases (78/79) with their feet (23/29), and
-- the glass drink fridges (44-47) with their feet (62/63) -- all
-- drawn built INTO the back wall, exactly like the Center's
-- healing consoles
wall = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 78, 79, 90, 91 },
-- THE BACK WALL. It is drawn FOUR tile rows tall -- trim (40, or
-- the fridge tops 90/91), the SALE signs (78/79) or glass upper
-- (44/45), the black display niche (76/77) or glass lower (46/47),
-- and the cases' base and goods (23/29, 62/63) -- which is 32px of
-- artwork depicting ONE wall, not four things at four depths.
--
-- Pinned `wall` that is exactly what it became: every row got its
-- own 16px box marching north, so the only face you ever saw was
-- the southmost row (the cases' base), the signs and the niche hid
-- behind it, and the trim ended up lying flat as a gold shelf on
-- the roofs of the boxes behind. Laid out in depth instead of
-- stacked up.
--
-- `bookcase` is the class that collapses a tall drawing onto a
-- one-cell-deep box at its real drawn height, so the run of four
-- rows becomes a single 32px wall: bands from the south are base,
-- niche, sign, trim, its top face wears the trim, and the three
-- rows behind become hidden floor. Same treatment the shelf racks
-- below already get -- a Mart's back wall IS a wall of display
-- cases, and the geometry does not care which we call it.
bookcase = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 76, 77, 78, 79,
90, 91,
-- the free-standing shelf racks: TALL drawings, not
-- deep ones -- each rank collapses onto a
-- one-cell-deep shelf at its drawn height (the
-- Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as
-- its top display; 68/69/71 and 84/85/87 the goods
-- rows below
64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- The wall's own tiles are reused elsewhere, and the back wall is
-- the one place they are drawn on the map's TOP EDGE. So `bookcase`
-- is their default and this hands every other use back to `wall`,
-- which is what all of them were before: 40 is also the clerk's
-- booth back panel (under the shelf rows 80/81 or under itself), and
-- 40/76/77/90/91 all recur in INDIGO_PLATEAU_LOBBY, the ninth map on
-- this id, where they draw the hall and the lift bank.
--
-- NEVER put 0 in an `above` set. Map:tileAt does not answer nil off
-- the top of a map -- it border-extends, so row 0 reads the map's
-- borderBlock, which indoors is the black void block 0. Listing 0
-- to catch the Lobby's void-backed 90/91 fired the rule on every
-- Mart's fridge row as well: row 0 dropped out of the bookcase run,
-- the fridges came out 24px against the SALE cases' 32px, and their
-- trim row stood as a separate box BEHIND the wall instead of on top
-- of it. Nothing can separate those two cases from above -- both
-- see void -- so the Lobby (already out of scope here) gets the
-- bookcase reading too, and the Marts come out right.
when_above = {
[40] = { { above = { 40, 80, 81, 83, 90, 91 }, class = "wall" } },
[76] = { { above = { 74, 75 }, class = "wall" } },
[77] = { { above = { 74, 75 }, class = "wall" } },
[90] = { { above = { 17 }, class = "wall" } },
[91] = { { above = { 27 }, class = "wall" } },
},
-- the clerk's counter, half a cell high like every service
-- counter. It is a C wrapping the alcove the clerk stands in: the
-- south arm's drawn front (24/25) under its top band (8/56), the
@@ -746,43 +888,66 @@ return {
-- cash register, pinned it `billboard`, and got a bare black
-- outline standing on the counter with the tile's own art replaced
-- by its neighbour's.
counter = { 8, 16, 24, 25, 41, 56, 89 },
-- The CASH REGISTER (14/15 over 30/31), a keypad and a curl of
-- receipt paper drawn face-on across two tile rows in the middle of
-- the counter's east arm. Pinned `counter` it was just paint on
-- the work surface. In the `billboard` pool it is a standing
-- per-pixel cutout ten voxels deep -- the flower-and-vase treatment
-- of Red's house, but with a machine's body rather than a stem --
-- and the support rule lifts it onto the counter automatically,
-- because the tile drawn directly BELOW it (16/41) is a pinned 8px
-- box. Its own pool, so the counter can never absorb it. The art
-- has a clean light margin on three sides and a black outline all
-- round, so the shade flood drains the work surface away and leaves
-- the machine whole.
--
-- `console`, not `billboard`, for the two vertical black rules the
-- COUNTER draws down the outer edges of those same tiles (columns
-- 0 and 15 of the pair, full height). They are the counter's own
-- edging, not the register, but black always survives the shade
-- flood, so the billboard pool extruded them too and the machine
-- stood flanked by a pair of tall black slabs. A white column
-- separates them from the register body, so they are their own
-- connected components -- and `console` carries the same
-- one-object contract `cutout` has, which keeps the largest
-- drawing and drops them, at ten voxels of body rather than one.
console = { 14, 15, 30, 31 },
-- the free-standing shelf racks: TALL drawings, not deep ones --
-- each rank collapses onto a one-cell-deep shelf at its drawn
-- height (the Dojo/Red's-house treatment). 64/65/67 and 80/81/83
-- are the bottle rows the clerk's booth also wears as its top
-- display; 68/69/71 and 84/85/87 the goods rows below
bookcase = { 64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 },
-- ...and the register's own two tile rows (14/15 over 30/31) are
-- part of that same work surface now that the machine has been cut
-- off them as a sprite -- see `figures` below.
counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 },
-- Left to the derived default on purpose: the floor checker and
-- its shadowed variants (1/11/17/26/27/54) and the exit mat
-- (12/28) all sit in cells the ROM marks walkable, so the cell
-- rule lays them flat unaided; 76/77 is the SALE case's black
-- interior, which the volume path recesses half a course into the
-- band -- a dark display niche, which is what it is drawn as.
-- rule lays them flat unaided. (76/77, the SALE case's black
-- interior, used to be left to the volume path here; it is now the
-- back wall's third band, because a run of four rows has to be
-- contiguous for the wall to collapse into one box at all.)
-- THE CASH REGISTER: a keypad and a curl of receipt paper drawn
-- face-on across two tile rows in the middle of the counter's east
-- arm. Like the Center's seated man it is a FIGURE drawn into the
-- furniture, so it gets the same treatment -- a flat sprite card
-- standing on the counter -- and for the same reason: no class pin
-- can separate it from the counter, because the counter draws its
-- own edging down columns 0 and 15 of the very same tiles.
--
-- That edging is what defeated every automatic reading. Black
-- always survives the shade flood, so the standee pools extruded
-- the two rules along with the machine and it stood flanked by a
-- pair of tall black slabs; `console`'s keep-the-largest-drawing
-- rule dropped them, but only by throwing away the receipt curl
-- too whenever the flood happened to cut it loose. The mask just
-- says where the machine is: columns 2-13, plus the curl climbing
-- to the top right, and the counter keeps its edging and its light
-- work surface.
--
-- `under` is 16/41 twice -- the plain work surface, which already
-- carries the west edging (black over white) and the east (dark
-- over black), so the counter closes up behind the machine with no
-- synthesis at all.
figures = {
{
w = 2,
tiles = { 14, 15,
30, 31 },
under = { 16, 41,
16, 41 },
pixels = {
".........XX.....",
"........XXXX....",
"........XXXXX...",
"........XXXXXX..",
"...XXXXXXXXXXX..",
"..XXXXXXXXXXXX..",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
"..XXXXXXXXXXX...",
},
},
},
-- NOT covered: INDIGO_PLATEAU_LOBBY, the ninth map on this id and
-- the only one that is not the 4x4 shop. It draws its own hall,
-- lift bank and rope stanchions from tiles no Mart places, three
@@ -2100,6 +2265,231 @@ return {
-- south-east). Neither map places them; they belong with the
-- wall band above if one ever does.
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
-- TWO courses (32px) for the masonry that ENCLOSES both maps -- the
-- plateau's rim and every wall around the terraces. It is drawn two
-- cells tall, which is exactly the height of a statue on its plinth
-- (a 16px plinth under a 16px standee), and that is the read: you are
-- walking in a walled compound whose wall matches the statues lining
-- it, not a room with a 16px skirting. At one course the rim was a
-- kerb you appeared to look over.
--
-- $03 the striated rock face, $0D/$0F/$0E the League's outer wall
-- (top band, face, base -- the same three tiles draw the League's
-- facade, the walls flanking the avenue and every badge-check gate
-- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its
-- cap. These four groups are the enclosure.
cliff = { 3, 13,
32, 33, 46, 47 },
-- THE GATE WALLS, stacked rather than laid out in depth. $0D/$0F/$0E
-- (13/15/14 -- top band, face, base) draw the League's facade, the
-- walls flanking the avenue and every badge-check gate down ROUTE_23,
-- as FOUR tile rows: 13 / 15 / 15 / 14. That is 32px of artwork
-- depicting one wall, and at one class per row it built four boxes
-- marching north, so the wall was 32px DEEP -- you saw the southmost
-- row's face and the rest hid behind it.
--
-- `bookcase` collapses the run onto a single one-cell-deep box at its
-- full drawn height: bands from the south are base, face, face, top
-- band, and the two rows behind are vacated. Same mechanism the
-- Mart's back wall uses.
-- ...and the PILASTER stacks with it. $05/$06 is its shaft, and it
-- is drawn only ever inside a pilaster, so it needs no rule. Without
-- this the pale columns stood 16px against a 32px brown wall and
-- their top vertices sat a whole course below the wall's crown.
bookcase = { 14, 15, 5, 6 },
-- Tile 13 is DUAL-USE and needs resolving per position. It is the
-- gate wall's TOP BAND (block $28's first row) and it is also the
-- BASE COURSE under a column of rock face (blocks $18/$1A/$1B, last
-- row). Pinned `bookcase` outright the second use became a one-row
-- rank -- an 8px stub under the rock, 352 of them over both maps.
--
-- ABOVE cannot tell them apart, which is what `when_below` is for.
-- Scanned over both maps through the engine's own tileAt, the tile
-- above a 13 is the rock face $03 for 140 base courses AND for 64
-- gate bands -- so a rule on `above` misfires on those 64 (it did:
-- their runs came out 2 and 3 bands instead of 4). BELOW splits it
-- exactly: the wall's own face $0F sits under the top band and under
-- nothing else, 336 against 352. So 13 defaults to `cliff` and is
-- promoted where the face is drawn beneath it.
--
-- 14 and 15 need no rule: 14 only ever sits under 15, 15 only ever
-- under 13 or 15.
when_below = {
[13] = { { below = { 15 }, class = "bookcase" } },
-- The pilaster's CAP ($15/$16) is the same stone as the statue's
-- plinth top -- the artist drew it twice -- so it too resolves per
-- position. Scanned over both maps: a cap with the shaft $05/$06
-- beneath it is a pilaster (76 of them) and one with the plaque
-- base $30/$31 beneath it is a statue's plinth (47). Default
-- `wall`, promoted here, so the plinth keeps its single course and
-- the statue standing on it still totals the 32px the wall is.
[21] = { { below = { 5 }, class = "bookcase" } },
[22] = { { below = { 6 }, class = "bookcase" } },
},
-- The other two ends of the same pair of drawings, keyed the other
-- way round. A pilaster is capped at BOTH ends, so its lower cap has
-- the shaft ABOVE it (8 placements); and the plaque base $30/$31 is a
-- pilaster's foot when the shaft is above it (68) but a statue's
-- plinth bottom when the cap $15/$16 is (47).
when_above = {
[21] = { { above = { 5 }, class = "bookcase" } },
[22] = { { above = { 6 }, class = "bookcase" } },
[48] = { { above = { 5 }, class = "bookcase" } },
[49] = { { above = { 6 }, class = "bookcase" } },
},
-- The vacated rows behind a collapsed wall take the cell ABOVE the
-- run rather than the default hidden floor: here that is more rock
-- face on ROUTE_23 and the plateau's paving or grass on INDIGO_
-- PLATEAU, so the wall reads as set INTO the terrace instead of
-- standing in front of a trench of synthesized ground.
bookcase_backfill = "above",
-- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
-- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
-- IS the statue's plinth, the artist having drawn the same stone
-- twice. Raising it would carry every statue standee up to 48px and
-- break the very match the cliff height was chosen for.
-- $05/$06 is NOT here: it moved to `bookcase` above, and a tile
-- listed in two groups resolves by whichever `pairs` order wins --
-- half the pilasters kept the shaft as `wall`, which broke the run
-- and left their caps as isolated 8px stubs. One group per tile.
wall = { 21, 22, 48, 49 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
@@ -2845,146 +3235,5 @@ return {
slab = 4, frontEave = 4, ledge = nil,
},
},
-- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-- forecourt the League itself stands on) and ROUTE_23 (the long
-- climb up to it, with its badge-check gates). Everything this
-- blockset draws is one piece of architecture -- striated rock
-- walls, white pillars, and the bird STATUES that line both the
-- avenue and the plaza. 29 of its 73 blocks are never placed, so
-- every tile named below really is one of these two maps'.
--
-- A statue is built exactly like the badge gyms' (see GYM above):
-- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them --
-- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-- stand the same statue on grass are never placed).
--
-- What the detector made of them is the bug this entry exists for.
-- On the avenue the statues stack with NO gap: the plinth's plaque
-- row is drawn directly above the next figure's head, so the
-- flood-fill joined all six of a column into ONE region 24 tile rows
-- tall, and the volume builder's repeat scan read 32px down one half
-- of the drawing and 24px down the other. Each row came out as a
-- continuous stepped RIDGE of boxes wearing the statue art folded
-- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues
-- stand alone and fared no better: 24px boxes with the figure's top
-- row skipped outright.
--
-- Pinned the gyms' way the ridge becomes statues. The plinth is a
-- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-- deep (the thin `prop` pool) that rides the plinth's top face
-- through the authored-box support rule and collapses to the
-- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-- so the base never marches backwards.
--
-- The one thing the gyms did not have to deal with: $28/$29 is
-- SHARED. The same bird is drawn again at the foot of every white
-- pillar, framed there by the pillar's black edge ($25/$26 over that
-- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-- four on INDIGO_PLATEAU (its two outer corners and the pair
-- flanking the League's recess). So $25/$26 joins the same pool:
-- pinning half a cell would have stood a half-height bird under a
-- wall. That in turn is why the pillar itself is pinned -- see the
-- last paragraph of `wall`.
PLATEAU = {
-- ONE 16px course for every piece of masonry here.
--
-- $03 is the striated rock face, 2436 placements and the bulk of
-- both maps. It already read 16 nearly everywhere, but in the
-- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-- the repeat scan came out 24 -- a stagger in the plateau's
-- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-- their two mirrors). Authored, the rim is one course.
--
-- $0D/$0F/$0E are the League's outer wall -- top band, face and
-- base. The same three tiles draw the Pokemon League's own
-- facade, the long walls flanking the avenue, and every
-- badge-check gate down ROUTE_23.
--
-- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-- the plaque base. $15/$16 over $30/$31 IS the statue's plinth --
-- the artist drew the same stone twice -- which is why one pin
-- serves the gate corners and the statues alike.
--
-- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-- HEIGHT: they derive 16 already. What the pin buys is
-- `authored`, which is exactly what the prop support rule tests.
-- Without it a pillar-foot bird finds no support, drops to ground
-- level, and leaves a hole punched clean through the pillar
-- (probed, shot, then fixed).
wall = { 3,
13, 14, 15,
21, 22, 48, 49, 5, 6,
32, 33, 46, 47 },
-- The statues, and the same bird at the pillar feet. Black-outline
-- segmented: the outline and everything it encloses stay, the sky
-- and the paving around them flood away.
prop = { 16, 18, 40, 41, 37, 38 },
-- Round drawings, one voxel ball per 16x16 cell -- the treatment
-- the overworld's canopies and Celadon's hedge take.
--
-- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-- them together is never placed). Boxed, the canopy art smeared
-- down the whole pillar column beneath it.
--
-- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-- middle terrace (blocks $02/$13/$16), one per cell and NOT
-- walkable. As boxes they sat flat enough to look painted onto
-- the path; as balls they read as the obstacles they are.
cylinder = { 7, 8, 23, 24,
29, 34, 42, 43 },
-- The Route 23 sign, one cell, block $48's only placement.
-- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-- with its board skipped. Same thin plate on a stick every other
-- outdoor sign gets.
signpost = { 9, 10, 25, 26 },
-- The Route 22 gate's roof, drawn from ABOVE and filling
-- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-- edges, $40/$41 the corners). Art on the TOP face -- the way
-- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-- It stands past the map's last walkable row (you warp to
-- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-- than a fix.
roof = { 61, 62, 64, 65, 68 },
-- The ground painted where a pinned figure's cell used to be:
-- $23, the pale paving both maps are floored with. It is also the
-- white the pillars are drawn in, so the cell a pillar-foot bird
-- vacates reads as more pillar -- where the neighbour vote left a
-- BLACK hole, having nothing to elect (all four neighbours of that
-- cell are wall). On the avenue and the plaza the statues' own
-- cells simply keep the paving they stand on.
prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
[37] = 35, [38] = 35 },
-- Deliberately NOT pinned:
--
-- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are
-- two of the three stale-cache water ids the trap is named for,
-- but here they are honestly water: TILEANIM_WATER animates $14,
-- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-- water cells as the waterline itself, so the cell rule dropping
-- them to -2 is what the art means. Left to fall through to the
-- engine's water set. ($48, the third trap id, is not in this
-- atlas at all -- it stops at $45.)
--
-- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-- and Victory Road's two. They are the tileset's own doorTiles,
-- so the door fold already stands them upright in the facade;
-- probed 16px identically before and after this entry.
--
-- $23/$2C/$2D are in the walkable list outright, and $45 is the
-- tileset's grassTile, which derives its own standing-tuft pin.
--
-- The Victory Road entrance is a `buildings` template
-- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-- and $15/$16/$05/$06. Buildings claim their tiles before any of
-- the above can reach them -- probed `b` class over all 216 of
-- them, unchanged by this entry.
},
},
}
+366
View File
@@ -0,0 +1,366 @@
-- 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")
-- rig and rays at the arena's OWN floor height: on solved terrain a
-- fight on a plateau stands (and is judged) that many pixels up, or
-- the raised ground itself would read as an obstacle over every mon
local gy = heightAt(map, arena.player[1], arena.player[2])
local ok, rig = pcall(BattleCam.rig, arena, gy)
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], gy + 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
+137
View File
@@ -0,0 +1,137 @@
-- 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.
--
-- Which puts this card OFF the voxel grid, alone among the meshes in this
-- mode: the rest are built one unit per voxel in their own model space --
-- terrain in world pixels, a character's card in the sprite's own pixels --
-- and the wireframe is the integer planes of that space (see VoxelGrid).
-- One unit here is the whole card, so the only integer plane inside it is
-- x = 0, which is the pic's centre column: a single stray line straight
-- down the middle of every Pokemon and no seams anywhere else.
--
-- The card stays a unit card, because a mon's size on screen is decided by
-- the artwork's own dimensions and the distance it is standing at, and a
-- unit card is what lets one matrix say both. Whoever draws it turns the
-- wireframe off instead (Voxel3D.seams) -- a mesh that is not on the voxel
-- grid does not get a voxel grid drawn on it.
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)
-- off the voxel grid, so no wireframe on it (see unitQuad)
Voxel3D.seams(false)
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
BattleBillboard.PULL)
Voxel3D.seams(true)
return true
end
-- The same card, as the SUN sees it: no camera-ward pull (that is a trick
-- for the view's own depth buffer and would drag the shadow off its owner)
-- and no draw call of its own, because the shadow pass has its own.
function BattleBillboard.caster(shadowMap, tex, x, y, z, grow)
local mesh = unitQuad()
if not (mesh and tex) then return false end
local pw, ph = tex:getDimensions()
local w, h = BattleBillboard.sizeFor(pw, ph)
if grow then w, h = w * grow, h * grow end
if w <= 0 or h <= 0 then return false end
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
shadowMap.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw))
return true
end
function BattleBillboard.invalidate()
quad = nil
end
return BattleBillboard
+193
View File
@@ -0,0 +1,193 @@
-- Overworld battles: the over-the-shoulder camera and its parallax drift.
--
-- The two mons are PINNED to their cells: each pic is drawn wherever its
-- patch of ground projects to, not at a fixed screen slot. So the camera is
-- not decoration -- it is the thing that decides where the fight appears,
-- and it has to put those two patches of ground exactly where the battle
-- screen wants its two pics:
--
-- the player's mon (26, 96) back pic, feet on the text box, well left
-- the enemy's mon (124, 56) front pic, bottom of the 7x7 slot
--
-- Four screen coordinates, so four equations. The rig below is the solution:
-- SIDE / BACK / HEIGHT place the eye relative to the arena's midpoint, LOOK
-- aims it, and FRAME_H sets the lens, and together they land both marks
-- within a thousandth of a pixel of the targets. They are not hand-picked
-- numbers that looked about right -- they came out of a solver, and the
-- suite reprojects them so a future edit either still lands or says so.
--
-- East is what decides which mon is on which side. The arena axis runs north
-- (the enemy) to south (the player's mon), and a camera east of that axis
-- sees the near end swing LEFT and the far end RIGHT -- the layout arrived
-- at by standing in the right place rather than by mirroring anything.
--
-- ------- and two more equations, from the pixels
--
-- The pics are pixel art and their size on screen is not something the mod
-- gets to choose: 56 pixels for a front pic, 64 for a back one. And a mon has
-- to stand in ONE OVERWORLD SQUARE, or it towers over the houses and gives
-- away that the world behind it is a picture. Together those say the square
-- each mon stands on must project to about the width of its own pic, which is
-- two more equations for the same six unknowns -- and they are what set the
-- distance.
--
-- The answer is a LONG LENS FROM A LOW STANCE: twelve degrees above the
-- floor, twelve degrees wide, from five blocks back. Not a stylistic choice
-- -- it is what a 56-pixel sprite standing on a 16-pixel tile forces on a
-- 160-pixel screen. Roughly three tiles fit across the frame, so the camera
-- has to be far away and zoomed in rather than near and wide. That is the
-- DEFAULT rig, and every map that can take it gets it.
--
-- ------- the exception: rooms too small to stand back from
--
-- Five blocks back is further than some rooms are wide. A gym is about ten
-- cells across, so on one the eye lands OUTSIDE the map, where the border
-- ring the engine draws round every map -- extruded into a cliff by this mode
-- -- crosses the near Pokemon wherever it stands. Three gyms could not be
-- staged anywhere at all for that reason.
--
-- So there is a second rig, and an arena asks for it by name (cam = "wide"
-- in data/battle_arenas.lua). It comes in to about four cells with the lens
-- opened up to match: an ordinary 44-degree shot that fits inside the room.
-- The mons render smaller for it -- a bit over half a tile rather than a
-- whole one -- which is the price. Both rigs are solved against the SAME four
-- anchors, so the composition is identical either way; only the lens and the
-- distance differ, which is what makes it safe to pick per map.
--
-- Rooms too small for the long lens are the reason it exists, but it is not
-- only for them: an area that simply reads better with more of itself in
-- shot can ask for it too.
--
-- Purely presentational, like everything else in this mod: the camera looks
-- at the map, and nothing it does reaches collision, movement or scripts.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleCam = {}
-- ------- the rig, in world pixels (a map cell is 16, a block 32)
--
-- Solved against the four anchors and two spans above, with the two mons 48
-- world pixels (three cells) apart -- BattleArena.SHAPES is where that gap is
-- set, and changing it invalidates these.
-- `frameH` is how much world the frame is tall enough to hold at the aim
-- distance, which together with that distance is the lens.
-- Named for the LENS, because that is what an author is choosing between
-- when they look at a shot and decide it wants more room in it.
BattleCam.RIGS = {
-- the default: a long 11.5-degree lens from five blocks back, which is
-- what makes one tile big enough to stand a 56-pixel mon on
tele = {
side = 78.79, back = 144.96, height = 37.88,
lookX = -0.26, lookY = 0.34, frameH = 34.11,
},
-- 44 degrees from four cells: fits inside a room the long lens cannot
-- stand back from, and shows more of anywhere else, at the cost of a
-- smaller pair
wide = {
side = 41.98, back = 41.16, height = 28.48,
lookX = -3.24, lookY = -1.35, frameH = 55.62,
},
}
BattleCam.DEFAULT_RIG = "tele"
-- The rig an arena asks for, falling back to the default for anything that
-- does not ask (and for a name that is not one of the two).
function BattleCam.rigFor(arena)
local want = arena and arena.cam
return BattleCam.RIGS[want] or BattleCam.RIGS[BattleCam.DEFAULT_RIG]
end
-- ------- the drift
--
-- A slow orbit about the arena's vertical axis. Rotating about a point
-- BETWEEN the two mons is what makes it parallax rather than a pan: the mons
-- are pinned to the ground, so the near one slides one way across the frame
-- and the far one slides the OTHER, by the amount their difference in
-- distance implies. Over a full swing that is about eight pixels of relative
-- movement -- plainly visible as depth, far too slow to fight the fight.
-- The angle is small because the lens is long: two degrees of orbit is seven
-- pixels of travel through an eleven-degree field of view.
--
-- Under it, a much smaller breath in and out along the same line, on an
-- unrelated period, so the pair never returns to the same pose on any cycle
-- a battle is long enough to show. A DOLLY rather than a pan of the aim:
-- moving the aim point would slide both mons the same way, which with pinned
-- pics is just the whole picture walking sideways. Changing the DISTANCE
-- moves them apart and back together about the frame's centre, which is the
-- same depth cue the orbit gives, from the other axis.
BattleCam.PAN_YAW = math.rad(2) -- half-angle of the orbit
BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
BattleCam.DOLLY_PERIOD = 37
BattleCam.t = 0
function BattleCam.reset()
BattleCam.t = 0
end
-- Real frame time, like every other presentational tween in this mod: a
-- fast-forwarded battle must not spin the camera.
function BattleCam.update(dt)
BattleCam.t = BattleCam.t + (dt or 0)
-- keep the phase small forever rather than letting a long session lose
-- float precision in the sines below
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
end
local function phase(t, period)
return math.sin(2 * math.pi * t / period)
end
-- The camera for `arena` this instant: the record Voxel3D.camera takes, plus
-- the pitch the pull and the sun frustum want (measured from straight down,
-- the same convention Voxel.angle uses).
--
-- `fov` here frames the GB's 160x144. A caller rendering at window
-- resolution widens it for the extra picture around that frame -- see
-- BattleScene.letterboxFov, which is what keeps the pins exact at any window
-- size.
--
-- `groundY` is the height of the arena floor, so a fight staged on a ledge
-- or a raised walkway is shot from above THAT rather than from inside it.
function BattleCam.rig(arena, groundY)
groundY = groundY or 0
local R = BattleCam.rigFor(arena)
local mx, mz = arena.mid[1], arena.mid[2]
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
local c, s = math.cos(yaw), math.sin(yaw)
-- the breath scales the whole offset, height included, so the eye moves
-- along its own line to the arena and the pitch of the shot never changes
local k = 1 + BattleCam.PAN_DOLLY
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
local dx = (R.side * c - R.back * s) * k
local dz = (R.side * s + R.back * c) * k
local eye = { mx + dx, groundY + R.height * k, mz + dz }
local focus = { mx + R.lookX, groundY + R.lookY, mz }
local ex = eye[1] - focus[1]
local ey = eye[2] - focus[2]
local ez = eye[3] - focus[3]
local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
local horiz = math.sqrt(ex * ex + ez * ez)
return {
eye = eye,
focus = focus,
fov = 2 * math.atan((R.frameH / 2) / dist),
-- the world curve is a free-roam flourish that bends the horizon away
-- from the player; a fixed camera on a staged shot has no player to bend
-- around, and the bend would tip the arena floor out from under the mons
-- the pics are pinned to
curve = 0,
}, math.atan2(horiz, math.max(1e-3, ey))
end
return BattleCam
+187
View File
@@ -0,0 +1,187 @@
-- Overworld battles: the depth-of-field pass over the arena.
--
-- A photographic lens focused on two subjects three cells apart holds a
-- narrow slab of the world sharp and loses everything in front of and
-- behind it. On this shot that slab is a BAND ACROSS THE FRAME, because the
-- camera is fixed and looking down at a floor: distance from the lens runs
-- monotonically up the picture, so screen height IS depth, and a band of
-- rows is a slab of world. The floor the mons stand on stays sharp, the
-- middle distance and the horizon soften, and the foreground the camera is
-- leaning over softens the other way.
--
-- BOTH MONS ARE IN FOCUS, and not by tuning: they are drawn as the battle
-- screen's own pics AFTER this runs, so they are never blurred at all. This
-- pass only ever touches the world behind them -- which is exactly the
-- separation a real shot of two subjects at the same distance would have.
--
-- Two separable gaussian passes over a 160x144 image: two full-frame draws
-- of 23,040 pixels, which is nothing, and it buys the one cue that stops a
-- 3D backdrop reading as wallpaper.
local BattleDOF = {}
-- Switched off for now. The pass is kept whole -- band maths, shader,
-- canvases -- because the reason to have it has not gone away: it is the one
-- cue that separates the pair from the ground behind them. It is off because
-- the mons are geometry in the scene now rather than pics over it, and a blur
-- that softens their own tile softens THEM, which the pinned-pic version
-- never had to answer for. Turning it back on means solving that first.
BattleDOF.ENABLED = false
-- Fallbacks for the band, in canvas uv. The caller normally measures it off
-- the two ground marks -- the whole point is that the slab in focus is the
-- one the mons are standing in -- and these are what a caller that cannot
-- gets instead.
BattleDOF.FOCUS_Y = 0.52
BattleDOF.BAND = 0.16
BattleDOF.RANGE = 0.32
-- How much floor either side of the two marks stays sharp, as a fraction of
-- the gap between them: a mon is taller than the patch it stands on, and the
-- ground just in front of and behind it belongs to the same slab.
BattleDOF.BAND_MARGIN = 0.55
-- How far past the band it takes to reach full blur, in the same units as
-- the band itself.
BattleDOF.RANGE_SCALE = 2.0
-- Tap spacing at full blur, as a fraction of the canvas height, so the blur
-- is the same depth of field in a window and fullscreen. The gaussian's
-- reach is four taps and the two passes compound, so a little goes a long
-- way.
BattleDOF.SPACING = 0.0095
-- A touch of saturation on the way out, the same trick the tilt-shift pass
-- uses: a blurred background reads as further away when it is also a
-- little richer than the sharp subject in front of it.
BattleDOF.SATURATION = 1.12
local SHADER = [[
uniform vec2 dir; // one texel step along the axis being blurred
uniform float focusY;
uniform float band;
uniform float range;
uniform float spacing;
uniform float boost; // 0 = plain blur pass, 1 = final pass (colour pop)
uniform float saturation;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float d = abs(tc.y - focusY) - band;
float s = clamp(d / range, 0.0, 1.0);
s = s * s; // ease in, so the band edge has no visible seam
vec2 o = dir * (s * spacing);
vec4 sum = Texel(tex, tc) * 0.2270270270;
sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946;
sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216;
sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541;
sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162;
if (boost > 0.5) {
float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114));
sum.rgb = mix(vec3(luma), sum.rgb, saturation);
}
return sum * color;
}
]]
local shader = nil -- nil = untried, false = unavailable
local ping, pong, cw, ch = nil, nil, 0, 0
local function getShader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER)
shader = (ok and sh) or false
end
return shader or nil
end
-- Its own pair of canvases rather than the tilt-shift pass's: those are
-- sized to the window and this is sized to the GB frame, and sharing them
-- would reallocate both every time a battle started or ended.
local function getCanvases(w, h)
if not ping or cw ~= w or ch ~= h then
local ok, a = pcall(love.graphics.newCanvas, w, h)
if not ok then return nil end
local okB, b = pcall(love.graphics.newCanvas, w, h)
if not okB then return nil end
-- the gaussian's fractional tap offsets need linear filtering
a:setFilter("linear", "linear")
b:setFilter("linear", "linear")
ping, pong, cw, ch = a, b, w, h
end
return ping, pong
end
-- The sharp band for a shot whose two ground marks land at canvas rows
-- `y1` and `y2`, as (focusY, band, range) in uv. This is the depth of field
-- proper: the band is the slab of world the two mons occupy, and everything
-- nearer or further softens away from it.
function BattleDOF.bandFor(y1, y2, h)
if not (y1 and y2 and h and h > 0) then
return BattleDOF.FOCUS_Y, BattleDOF.BAND, BattleDOF.RANGE
end
local mid = (y1 + y2) / 2 / h
local half = math.abs(y1 - y2) / 2 / h
local band = half * (1 + BattleDOF.BAND_MARGIN)
return math.min(1, math.max(0, mid)),
band,
math.max(1e-3, band * BattleDOF.RANGE_SCALE)
end
-- Run the pass over `canvas` and return the result, or the input unchanged
-- when it cannot run (headless, no shader support) -- so the caller always
-- has something to composite. `focusY`, `band` and `range` are in canvas uv;
-- omit them for the fixed fallback band.
function BattleDOF.apply(canvas, focusY, band, range)
if not (canvas and BattleDOF.ENABLED) then return canvas end
local sh = getShader()
if not sh then return canvas end
local w, h = canvas:getDimensions()
local a, b = getCanvases(w, h)
if not a then return canvas end
focusY = focusY or BattleDOF.FOCUS_Y
band = band or BattleDOF.BAND
range = range or BattleDOF.RANGE
local prevBlend, prevAlpha = love.graphics.getBlendMode()
local prevCanvas = love.graphics.getCanvas()
-- the scene canvas filters nearest for its 1:1 blit; the taps need linear,
-- restored below so the composite sees what it expects
canvas:setFilter("linear", "linear")
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
-- replace, not alpha-blend: these are image-processing copies
love.graphics.setBlendMode("replace", "premultiplied")
pcall(sh.send, sh, "focusY", focusY)
pcall(sh.send, sh, "band", band)
pcall(sh.send, sh, "range", range)
pcall(sh.send, sh, "spacing", math.max(0.75, h * BattleDOF.SPACING))
pcall(sh.send, sh, "saturation", BattleDOF.SATURATION)
local ok = pcall(function()
love.graphics.setCanvas(a)
pcall(sh.send, sh, "dir", { 1 / w, 0 })
pcall(sh.send, sh, "boost", 0)
love.graphics.draw(canvas)
love.graphics.setCanvas(b)
pcall(sh.send, sh, "dir", { 0, 1 / h })
pcall(sh.send, sh, "boost", 1)
love.graphics.draw(a)
end)
if prevCanvas then
love.graphics.setCanvas(prevCanvas)
else
love.graphics.setCanvas()
end
love.graphics.setShader()
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
canvas:setFilter("nearest", "nearest")
return ok and b or canvas
end
-- Drop the GPU objects (window resize, hot reload).
function BattleDOF.invalidate()
ping, pong, cw, ch = nil, nil, 0, 0
end
return BattleDOF
+201
View File
@@ -0,0 +1,201 @@
-- Leaving a battle: the fade the way back to the map never had.
--
-- Going IN is a whole production -- one of the original's eight wipes, picked by
-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a
-- hard cut: BattleState:finish pops itself and the map is simply THERE on the
-- next frame. On the flat battle screen that is a cut between a white field and
-- a tile map, which the original got away with. In this mode it is a cut between
-- a placed camera looking across an arena and a diorama looking down on a
-- walking player, and a jump that big reads as a glitch rather than as an edit.
--
-- So the battle fades out, closes behind the black, and the map fades up out of
-- it. The timing is a transitions record this mod registers rather than a
-- constant in here, so it is retunable in data like the engine's own eight.
--
-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a
-- vanilla battle keeps the cut it always had. While the mode IS on, every battle
-- gets it -- including one that found no arena and drew on the flat battle
-- screen -- because what is being smoothed over is the return to the MAP, and
-- the map is a diorama either way.
--
-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws
-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED
-- composite in a wrap around Renderer:endFrame paints it -- after the world
-- blit, after the letterbox, after the UI blit, which is the only point where a
-- single rect covers everything on screen at once.
--
-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted
-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that
-- borrowed it would darken the arena and leave the battle's text box sitting
-- bright on top of the black -- and the letterbox bars of a flat battle screen,
-- painted by the renderer's clear before any state draws, would not darken at
-- all.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local BattleExit = {}
BattleExit.__index = BattleExit
-- The battle underneath keeps drawing while this is up -- what fades is its own
-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so
-- nothing the battle does can outrun the fade either.
BattleExit.isOpaque = false
-- The registered record's id, and the fallback timing if it is missing (a
-- headless caller, or a total conversion that dropped the namespace). Per HALF,
-- matching the engine's warp fade, so the whole edit is 24 frames.
BattleExit.ID = "voxel_battle_exit"
BattleExit.FRAMES = 12
-- The fade in progress, or nil. Kept here rather than on the state so the
-- endFrame wrap has one place to look and nothing can go stale the frame after
-- the state leaves the stack.
local live = nil
-- How black the composite is this frame: 0 on the battle's last live frame, 1 at
-- the cut, 0 again once the map is up. nil when no fade is running, which is
-- every other frame the game ever draws.
function BattleExit.veil()
if not live then return nil end
-- A fade can be taken off the stack by something that is not the fade: a
-- script or a shot driver popping down to the overworld, a state teardown.
-- Then it is not running, whatever its counter says -- and a veil left behind
-- would black the game out for good, because nothing is going to fade it back
-- in. Checked here rather than trusted, because this is the one place the
-- answer is used. The walk only happens while a fade is live.
local stack = live.game and live.game.stack
local states = stack and stack.states
local onStack = false
for i = #(states or {}), 1, -1 do
if states[i] == live then onStack = true break end
end
if not onStack then live = nil; return nil end
local a = live.t / live.frames
if live.phase == "in" then a = 1 - a end
return math.max(0, math.min(1, a))
end
local function framesFor(game)
local records = game and game.data and game.data.transitions
local record = records and records[BattleExit.ID]
local frames = record and record.frames
if type(frames) == "number" and frames > 0 then return frames end
return BattleExit.FRAMES
end
function BattleExit.new(game, battle, onMidpoint)
return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint,
frames = framesFor(game), t = 0, phase = "out" },
BattleExit)
end
-- Push the fade over the battle it is closing.
function BattleExit.start(battle, onMidpoint)
local game = battle.game
local self = BattleExit.new(game, battle, onMidpoint)
live = self
game.stack:push(self)
return self
end
function BattleExit:update()
self.t = self.t + 1
if self.t < self.frames then return end
self.t = 0
if self.phase == "in" then
live = nil
self.game.stack:pop()
return
end
-- ------- the cut, at full black
--
-- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while
-- this fade is up that is the fade -- so a fade that stayed would eat the
-- battle's own pop and leave the battle running underneath, finished but
-- still on the stack. Popping ourselves hands the top back to the battle so
-- its pop lands on itself.
self.phase = "in"
local stack = self.game.stack
stack:pop()
if self.onMidpoint then self.onMidpoint() end
if stack:top() == self.game.overworld then
stack:push(self) -- and the map comes up out of it
return
end
-- We are not going back to the map after all. Either the battle did not
-- actually leave -- finish() can be a false start, and wanted() mirrors the
-- one the engine has today -- or something else took the screen on the way
-- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns
-- the transition from here, so this one ends at the cut instead of fading in
-- over the top of it. The flag goes back too, so a second finish() that does
-- leave gets its own fade.
live = nil
if self.battle then self.battle.dramaticShapeLeaving = nil end
end
-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a
-- fault, and runnable on this machine. A function on the table rather than an
-- inline call so a driver or a headless test can pin it -- the test harness has
-- no depth buffer, where the honest answer is no on every rung.
function BattleExit.modeOn()
return require("src.render.Pipelines").eligible("voxel") and true or false
end
-- Whether this ending gets the fade.
function BattleExit.wanted(battle)
local game = battle and battle.game
if not (game and game.stack) then return false end
-- finish() is not always the end: an unpaid PAY DAY prints its takings and
-- comes back through here a moment later (BattleState:finish's first branch).
-- Mirrored read-only, so the fade starts on the call that really leaves rather
-- than fading to black and snapping back for one more message.
if battle.payDay and battle.result == "win" then return false end
return BattleExit.modeOn()
end
-- ------- engine seams
--
-- Two wraps, each idempotent so a hot reload cannot stack them.
function BattleExit.install()
local BattleState = require("src.battle.BattleState")
if not BattleState.dramaticShapeExitHook then
local inner = BattleState.finish
-- The one place a battle ends. Wrapped rather than listened for: the
-- battle.ended event is emitted AFTER the pop, and by then the battle
-- screen is gone and there is nothing left to fade out.
function BattleState:finish()
if self.dramaticShapeLeaving or not BattleExit.wanted(self) then
return inner(self)
end
self.dramaticShapeLeaving = true
BattleExit.start(self, function() inner(self) end)
end
BattleState.dramaticShapeExitHook = true
end
local Renderer = require("src.render.Renderer")
if not Renderer.dramaticShapeExitHook then
local inner = Renderer.endFrame
function Renderer:endFrame(zones, worldZones)
inner(self, zones, worldZones)
local a = BattleExit.veil()
if not a or a <= 0 then return end
-- The composite is on the screen by now, in LOVE units, so one rect over
-- the window darkens the world, the letterbox bars, the text box and
-- anything a present pass put on top, all by the same amount. Left to
-- last on purpose: this is a shutter closing on the finished frame, not a
-- layer inside it.
local w, h = love.graphics.getDimensions()
love.graphics.setColor(0, 0, 0, a)
love.graphics.rectangle("fill", 0, 0, w, h)
love.graphics.setColor(1, 1, 1, 1)
end
Renderer.dramaticShapeExitHook = true
end
end
return BattleExit
+408
View File
@@ -0,0 +1,408 @@
-- 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 same map for a rect that is ALREADY in world-canvas pixels. A HUD
-- snapped out to the window's edge has left the GB frame, so it has no GB
-- coordinates to be placed from -- see OverworldBattle.snapRects.
local function frostRectWorld(rect, box)
local kx = frostW / box.pw
local ky = frostH / box.ph
return rect[1] * kx, rect[2] * ky,
math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
end
-- Which of the two the caller's rects are in. One frost buffer, one panel
-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
-- canvas) or world pixels (rects land in the window-resolution world image).
local function mapper(world)
return world and frostRectWorld or frostRect
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, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local toFrost = mapper(world)
local darkest = nil
for key, rect in pairs(rects) do
local fx, fy, fw, fh = toFrost(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 that target's own
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
-- for a panel laid straight onto the world image.
--
-- 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, world)
if not (frost and box and box.scale and box.scale > 0) then return false end
local fx, fy, fw, fh = mapper(world)(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 whole HUD layer as a texture
--
-- The two blocks do not sit in the same place any more: each is snapped to its
-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
-- each block is then blitted out of it as a quad.
--
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
-- into the texture rather than composited into the caller's target -- the world
-- image the quads land on is a colour canvas, and a flip pass over it would
-- whiten the terrain behind the glyphs along with them.
local hudLayer = nil
function BattleHud.layerTexture(w, h, dark, fn)
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
hudLayer = canvasOf(w, h, "nearest")
if not hudLayer then return nil end
end
local g = love.graphics
local prevCanvas = g.getCanvas()
local prevBlend, prevAlpha = g.getBlendMode()
local ok, err = pcall(function()
g.setCanvas(hudLayer)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
-- flipGlyphs renders fn into its own scratch layer and composites the
-- whitened result into whatever is bound, which is this canvas
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
g.setBlendMode(prevBlend or "alpha", prevAlpha)
g.setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
return hudLayer
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
layer, hudLayer = nil, nil
end
return BattleHud
+266
View File
@@ -0,0 +1,266 @@
-- 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. Which pixels those
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
-- the hardware drew the mon's white belly and the white field behind it with
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
-- image says which was which. There is no distinction to recover; there is one
-- to draw.
--
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
-- can reach is background, and whatever it cannot is paper. What makes that
-- work is where the flood is allowed to start.
--
-- Start it at the image border and it fills everything and answers nothing.
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
-- out between two legs and off the bottom of the frame. Run over all 352 of
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
-- it left every mon a stencil, which is the bug this file exists to fix and
-- for a long time did not.
--
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
-- the left, the right and the top are seeded whole. The sky between a pair of
-- ears reaches the top edge and stays sky; the gap between a body and a raised
-- tail reaches the side and stays gap.
--
-- The BOTTOM is the interesting one, because two completely different things
-- meet the underside of a figure and they have to be told apart.
--
-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
-- on down until the artist stopped, leaking to the outside through the inch
-- between a body and a leg. Seal it: what is above it is the mon.
--
-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
-- that happens to be enclosed on three sides. Leave it open: the world
-- should show through the gap in a trainer's stride.
--
-- What separates them is how WIDE the opening is, and on this game's art that
-- is not a close call. Measured along the bottom of every battle pic: the
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
-- taken at 6 with room either side rather than tuned to a single sprite.
--
-- Apart from that one number the rule is exact: no pixel is filled for what
-- surrounds it, only because the background provably cannot get to it. And it
-- needs no idea whether it is holding a front pic, a back one or a trainer --
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
-- and they come back untouched because that is what their own shape says, not
-- because they were special-cased.
--
-- 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.
--
-- The canvas is forced to dpiscale = 1, and that is the whole difference
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
-- newImageData reads the magnified copy back at its own PIXEL size. The image
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
-- it trusts getWidth(), and the mon stands on the map nearly three times the
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
-- is already 1 there. Nor did every species, because only a pic with an
-- enclosed hole in it comes back through here at all (see `changed` below) --
-- so a Pidgey came out giant and the mon beside it did not, which is what
-- makes this read as a sprite bug rather than a scale one. See the engine's
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
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, { dpiscale = 1 })
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
-- The box the artwork actually occupies, or nil for a pic with no ink in it.
--
-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
-- whole rows and columns of nothing around itself. The bottom of THIS box is
-- the cut the rule below turns on, and the bottom of the image is just empty
-- frame some distance under it.
local function inkBounds(data, w, h)
local x0, y0, x1, y1 = w, h, -1, -1
for y = 0, h - 1 do
for x = 0, w - 1 do
local _, _, _, a = data:getPixel(x, y)
if a > CUT then
if x < x0 then x0 = x end
if x > x1 then x1 = x end
if y < y0 then y0 = y end
if y > y1 then y1 = y end
end
end
end
if x1 < x0 then return nil end
return x0, y0, x1, y1
end
-- The widest opening along the bottom of a figure that still counts as a drain
-- rather than a mouth. See the header for the measurements either side of it.
BattlePics.DRAIN = 6
-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
-- the edges of the artwork's box: the left, the right and the top whole, and
-- along the bottom only those openings wide enough to be background rather
-- than the underside of a figure the drawing ran out of.
--
-- Confined to the box as well as seeded from it, so the empty frame under a
-- short pic cannot walk around a sealed drain and come back up through it.
--
-- 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, x0, y0, x1, y1)
local outside = {}
local stack, top = {}, 0
local function clear(x, y)
local _, _, _, a = data:getPixel(x, y)
return a <= CUT
end
local function push(x, y)
if x < x0 or y < y0 or x > x1 or y > y1 then return end
local key = y * w + x
if outside[key] then return end
if not clear(x, y) then return end
outside[key] = true
top = top + 1
stack[top] = key
end
for x = x0, x1 do push(x, y0) end
for y = y0, y1 do
push(x0, y)
push(x1, y)
end
-- the bottom, run by run: a wide one is the gap between two legs and lets
-- the world through, a narrow one is where a belly ran out and is sealed
local x = x0
while x <= x1 do
if clear(x, y1) then
local from = x
while x <= x1 and clear(x, y1) do x = x + 1 end
if (x - from) > BattlePics.DRAIN then
for k = from, x - 1 do push(k, y1) end
end
else
x = x + 1
end
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 x0, y0, x1, y1 = inkBounds(data, w, h)
if not x0 then return end -- a pic with nothing drawn in it
local outside = markOutside(data, w, h, x0, y0, x1, y1)
local fill = BattlePics.FILL
local changed = false
-- only inside the box: everything beyond it is frame the artist never
-- reached, and filling that would put the mon in a white rectangle
for y = y0, y1 do
local row = y * w
for x = x0, x1 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
+557
View File
@@ -0,0 +1,557 @@
-- 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 DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
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)
ChunkMesher.request(host, false, nil, true)
local terrain, water = ChunkMesher.pair(host, false)
if not terrain then terrain, water = ChunkMesher.pair(host, true) end
return terrain, {}, water, {}
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),
-- the cycle keeps running through a fight, and an arena lit
-- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 128) }
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,
water, nbWater)
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
-- the water surface is its own reflective pass now (see Water) and so is
-- no longer inside the terrain mesh; the sun still has to see it, or the
-- light's map has a hole at every lake
ShadowMap.draw(water, atlasFor(host), nil)
for i, nb in ipairs(neighbors) do
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
-- keep contact with their bases instead of starting a bias-width away
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
ShadowMap.snug(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.
-- marked as the CAST, so a fight staged at the water's edge does not lay a
-- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's
-- own floor still takes them, which is the shadow that matters here
ShadowMap.sprites(true)
for _, card in ipairs(cards or {}) do
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
ShadowMap.snug(card.model))
end
ShadowMap.sprites(false)
ShadowMap.finish(sig)
end
-- The height of the arena floor: the ground the two mons stand on. Both
-- cells are open, so they are normally the same; take the player's, which is
-- the one nearer the camera and therefore the one a mismatch would show up
-- against.
function BattleScene.groundY(map, arena)
local ok, h = pcall(VoxelScene.groundAt, map,
arena.playerCell[1], arena.playerCell[2])
return (ok and h) or 0
end
-- Where a world point lands in GB frame coordinates under `vp`, or nil when
-- it is behind the camera. This is the function the pins are built on: it
-- takes the window-resolution clip position and divides the letterbox back
-- out of it, so the answer is in the same 160x144 space the battle screen
-- draws its pics in.
function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph)
local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4]
local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8]
local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16]
if cw <= 1e-6 then return nil end
-- viewProjection already flipped clip Y into LOVE's Y-down convention
local px = (cx / cw * 0.5 + 0.5) * pw
local py = (cy / cw * 0.5 + 0.5) * ph
return (px - lx) / s, (py - ly) / s
end
-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} },
-- the two marks in GB coordinates -- or nil when there is nothing to draw
-- yet (the terrain mesh is still building, the driver has no depth support).
-- nil is not a failure: the caller simply leaves the battle screen as the
-- engine drew it for that frame.
-- White, for the hit flash, and how far toward it the card goes.
--
-- The shader replaces the card's colour rather than multiplying it, so at
-- full strength this is the sprite turned into a solid white silhouette --
-- which is what the effect is on a flat GB screen and far too much on a
-- sprite standing in a lit world. Held well short of 1, the mon's own
-- shading still reads through the flash: it looks struck rather than
-- deleted.
BattleScene.FLASH_COLOR = { 1, 1, 1 }
BattleScene.FLASH_STRENGTH = 0.5
-- ------- the tile clock, while the overworld is not the one drawing
--
-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE
-- only advances it from OverworldState:drawWorld -- which runs under dialogs
-- and menus, but not under a battle, because a battle draws instead of the
-- overworld rather than over it. So for the length of a staged fight the
-- counter stood still: the water tiles stopped rotating their pixels and the
-- wave field, which is driven off the same number so the two cannot drift
-- (see Water), stopped with them. A lake in the background of a battle was a
-- photograph.
--
-- Ticked HERE rather than from the mod's update hook, because here is the
-- one place that means "a staged battle is drawing this frame, and the
-- overworld is not". From the update hook the condition would have to be
-- guessed at, and a frame where both ran would double the rate.
local function tickTiles()
local Game = require("src.core.Game")
local ow = Game and Game.overworld
local top = Game and Game.stack and Game.stack:top()
-- during the wipe INTO a battle the overworld can still be the one
-- drawing, and it is ticking the clock itself; two ticks in a frame would
-- run the water at double speed
if top and ow and top == ow then return end
pcall(require("src.render.TileRenderer").tick)
end
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
tickTiles()
-- 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 {}
-- the hour's light reaches the arena exactly as it reaches free-roam: the
-- shared rig follows the clock on an outdoor floor and stays at noon on an
-- indoor one, and the same tint multiplies the staged shot -- with the
-- same window glass on whatever buildings stand in the background
local outdoor = host.def and Map.isOutdoor(host.def) or false
DayNight.applyRig(outdoor)
-- a canopy floor (Viridian Forest) fights under the hour's tint too,
-- with the rig and the void exactly as they were
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host))
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
-- no glint in the arena: the drift is the shot breathing, not the player
-- moving, and a shimmer on background windows would fight the mons
Voxel3D.glassGlint = 0
-- 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, water, nbWater = 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, water, nbWater)
-- 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 -- and
-- the hour still has the last word: a sunset fades the arena's shadows
-- out and the moon presses more softly, exactly as it does outside
local sunWas = Voxel3D.SHADOW_ALPHA
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
* DayNight.shadowScale(outdoor)
-- 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
-- and the water over it -- PLAIN, always: the flat animated tiles, never
-- the reflective pass, whatever the WATER row says. The reflection is
-- tuned for the overworld's ladder of cameras; this shot's is PLACED --
-- low, tilted and framed like a picture -- and under it the pass reads
-- wrong: Fresnel opens all the way up, the leaned sky lands on bands the
-- framing never shows, and a lake-sized arena comes out as murk wearing
-- the tile art. The battle is a stage set, and stage water is painted.
-- (No mirror also means the mons need no second draw into one -- they
-- just composite over the water below, like everything else on the set.)
if water then Voxel3D.draw(water, atlasFor(host)) end
for i, nb in ipairs(neighbors) do
if nbWater and nbWater[i] then
Voxel3D.draw(nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
end
-- The mons, standing on their tiles. Depth-tested like everything else,
-- so a ledge or a tree between the camera and a Pokemon really is in
-- front of it, and the alpha discard cuts the sprite's own outline out of
-- the card. A small camera-ward pull keeps a card rooted to the ground
-- plane from z-fighting the tile it is standing on.
-- The engine's hit flash is a full-screen white rectangle, which on a
-- white battle field is a flash and over a world is a whiteout of the
-- map, the HUD and the text box alike. It is dropped on the way past
-- (see OverworldBattle) and put back HERE, on the two things it was ever
-- about: the mons themselves go solid white for those frames.
local flashing = textures and textures.flash
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
-- and no voxel wireframe on the pair. Everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that; a
-- mon's card is one quad wearing the battle screen (see
-- BattleBillboard), so it is off the grid and has no seams to draw.
Voxel3D.seams(false)
-- and no glass either: the cards wear the battle screen, not the
-- tileset atlas, so the mask's coordinates mean nothing on them
Voxel3D.glass(false)
for _, card in ipairs(monCards(arena, groundY, textures)) do
-- the sun stored this card snugged (castShadows), so its own shadow
-- lookup must read the same snugged transform -- see ShadowMap.snug
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL, ShadowMap.snug(card.model))
end
Voxel3D.glass(true)
Voxel3D.seams(true)
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
-- orbit's -- there is no character here for them to overdraw, but the
-- pull is also what keeps a tuft from z-fighting the floor it stands on
local pull = VoxelScene.pull(math.max(pitch, 0.05))
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
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,
-- and the hour's light, for anything drawn over this shot that is NOT
-- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
-- indoors, which is what DayNight.tint answers for a room.
tint = Voxel3D.tint,
}
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
+11 -4
View File
@@ -701,12 +701,19 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
vote(tx + bw, ty + r)
end
-- One building, ONE base: the model is rigid, so it stands at the
-- elevation under its door row and the pad beneath is flattened to
-- match -- a house near a ramp must not have terraced floorboards.
local my = S.base
and S.base[keyOf(tx + math.floor(bw / 2), ty + bh - 1)] or 0
for r = 0, bh - 1 do
for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r)
S.shapeAt[k] = shape
S.skip[k] = true
S.ground[k] = best or false
if S.base then S.base[k] = my ~= 0 and my or nil end
end
end
@@ -714,10 +721,10 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
local out = S.objectQuads
for _, q in ipairs(quads) do
out[#out + 1] = {
{ q[1][1] + mx, q[1][2], q[1][3] + mz },
{ q[2][1] + mx, q[2][2], q[2][3] + mz },
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
{ q[1][1] + mx, q[1][2] + my, q[1][3] + mz },
{ q[2][1] + mx, q[2][2] + my, q[2][3] + mz },
{ q[3][1] + mx, q[3][2] + my, q[3][3] + mz },
{ q[4][1] + mx, q[4][2] + my, q[4][3] + mz },
uv = q.uv, shade = q.shade,
-- placements only ever scan the BODY, so a building is always this
-- map's own structure: the mesher's edge keep-rules must not eat
+204 -50
View File
@@ -221,21 +221,40 @@ end
-- Kept free of any GPU call so it can be exercised headless -- the
-- geometry is the part with the interesting invariants, and a suite that
-- needed a real GL context to check them would never run in CI.
local function runGeometry(map, bodyOnly, masks, sink)
-- `waterSink`, when given, takes the WATER SURFACE quads instead of the
-- main sink -- the one class in this world that is drawn as its own pass
-- (see Water: a mirror cannot be drawn until what it reflects exists).
-- Nothing else moves: the quads are the same quads, emitted by the same
-- corner and uv arithmetic at the same recessed height, and the shoreline
-- faces around them still belong to the GROUND that exposes them.
--
-- Omitted, water stays in the terrain mesh exactly as it always did, which
-- is what the headless geometry() below and the sun's own pass both want.
local function runGeometry(map, bodyOnly, masks, sink, waterSink)
local push = sink.push
local waterPush = waterSink and waterSink.push or nil
local tileset = map.tileset
local S = Structures.forMap(map)
local perRow = tileset.tilesPerRow or 16
local atlasW = tileset.imageWidth or (perRow * 8)
local atlasH = tileset.imageHeight or 48
-- The terrain's base elevation under a tile (0 on a map without a
-- solved field -- every interior), and the ABSOLUTE height of what
-- stands there: base + the shape's own extrusion. Side faces are
-- derived from neighbour height differences, so once every height is
-- measured from the same datum the cliff skirt under a raised cell
-- falls out of the same band loop that has always clothed walls.
local baseAt = S.base and function(k) return S.base[k] or 0 end
or function() return 0 end
local function heightAt(tx, ty)
local k = keyOf(tx, ty)
if S.skip[k] then return 0 end
if S.skip[k] then return baseAt(k) end
local run = S.runs[k]
if run then return run.h end
if run then return baseAt(k) + run.h end
local s = S.shapeAt[k]
return s and s.h or 0
return baseAt(k) + (s and s.h or 0)
end
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
@@ -334,13 +353,17 @@ local function runGeometry(map, bodyOnly, masks, sink)
-- blocks half the sky, so the closer a voxel sits to it the less ambient
-- light reaches it -- which is what plants a prop on the floor instead
-- of leaving it looking pasted over the top.
-- `floor` is the terrain base the prop stands on: contact darkening
-- measures height above the prop's OWN ground, not above the world
-- datum, or every plant on a plateau would lose its feet.
local aoProp = { 0, 0, 0, 0 }
local function groundShades(c, shade)
local function groundShades(c, shade, floor)
if type(shade) == "table" then return shade end
floor = floor or 0
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
if math.min(y1, y2, y3, y4) >= AO_RISE then return shade end
if math.min(y1, y2, y3, y4) - floor >= AO_RISE then return shade end
for i = 1, 4 do
local t = c[i][2] / AO_RISE
local t = (c[i][2] - floor) / AO_RISE
aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t)))
end
return aoProp
@@ -358,12 +381,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
return aoSide
end
local function topQuad(x0, z0, h, tile, shade)
-- `to` routes the quad somewhere other than the main sink -- the water
-- surface is the only caller that ever does (see runGeometry's header).
local function topQuad(x0, z0, h, tile, shade, to)
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
push({ { x0, h, z0 }, { x0 + 8, h, z0 },
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
aoShades(x0 / 8, z0 / 8, h, shade))
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
aoShades(x0 / 8, z0 / 8, h, shade))
end
-- vertical quad for face direction `d` of the tile column at (x0, z0),
@@ -427,33 +452,49 @@ local function runGeometry(map, bodyOnly, masks, sink)
s = nil
end
-- Under the TREES fill the border wall is MODELLED or it is not there
-- (see Structures' hullRingOnly): a ring cell nothing claimed would
-- be a flat-topped box standing beside carved trunks, which reads as
-- a painted-on plateau rather than forest. Structures already stops
-- the ring at the carve distance; this catches the odd cell inside it
-- that the 2x2 grouping could not take -- a canopy whose partners
-- fall outside the shortened ring is left unclaimed, and one strip of
-- boxes along an edge is the whole artefact this avoids.
if not inBody and S.hideBareRing and not S.skip[k] then
s = nil
end
if s and S.skip[k] then
-- an object stands here; paint its synthesized ground and let the
-- prebuilt prism quads (appended below) carry the art
local g = S.ground[k]
if g then
topQuad(tx * 8, ty * 8, 0, g, 1)
-- the claimed tile is still ground at height 0, and water next
local b = baseAt(k)
topQuad(tx * 8, ty * 8, b, g, 1)
-- the claimed tile is still ground at its base, and water next
-- door still recesses below it: without the same below-ground
-- side bands ordinary ground emits, the two-pixel shoreline
-- face is a slit into the sky behind the mesh -- which is
-- exactly what a building plot or a sign standing at the
-- waterline showed. Same bands, cut from the synthesized
-- ground's own art
-- ground's own art. Bands run in CELL-LOCAL height (world
-- minus base), so the crop is the one the flat world always
-- drew, translated up with the terrain.
for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2])
if nh < 0 then
if nh < b then
local d = side[3]
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), -1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(0, band * 8 + 8)
if y1 > y0 then
sideQuad(d, tx * 8, ty * 8, y0, y1, g,
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
sideShades(hl, hr, y0, y1, y0 <= nh,
local nl = nh - b
for band = math.floor(nl / 8), -1 do
local ly0 = math.max(nl, band * 8)
local ly1 = math.min(0, band * 8 + 8)
if ly1 > ly0 then
sideQuad(d, tx * 8, ty * 8, b + ly0, b + ly1, g,
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
sideShades(hl, hr, b + ly0, b + ly1, ly0 <= nl,
Voxel3D.FACE_SHADE[d]))
end
end
@@ -462,7 +503,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
end
elseif s then
local run = S.runs[k]
local h = run and run.h or s.h
local b = baseAt(k)
-- h is ABSOLUTE (base + extrusion), matching heightAt; hLocal is
-- the extrusion alone, which is the space the art bands live in
local hLocal = run and run.h or s.h
local h = b + hLocal
local x0, z0 = tx * 8, ty * 8
-- top face. A roofed volume gets a GABLE segment: the roof rises
@@ -478,9 +523,10 @@ local function runGeometry(map, bodyOnly, masks, sink)
-- everything else its own art.
if run and run.rise > 0 then
local mid = run.extent / 2
local runTop = b + run.h -- the facade top, absolute
local function gableH(d) -- d = rows north of the south eave
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
return run.h + run.rise * math.max(0, math.min(1, t))
return runTop + run.rise * math.max(0, math.min(1, t))
end
local d0 = run.front - ty -- rows from the south edge
local hS = gableH(d0)
@@ -491,13 +537,13 @@ local function runGeometry(map, bodyOnly, masks, sink)
math.floor((1 - rel) * run.roofRows))
local roofTile = map:tileAt(tx, run.north + idx)
local swY, seY, neY, nwY = hS, hS, hN, hN
if heightAt(tx - 1, ty) < run.h then -- west flank: hip
swY = math.max(run.h, hS - 8)
nwY = math.max(run.h, hN - 8)
if heightAt(tx - 1, ty) < runTop then -- west flank: hip
swY = math.max(runTop, hS - 8)
nwY = math.max(runTop, hN - 8)
end
if heightAt(tx + 1, ty) < run.h then -- east flank: hip
seY = math.max(run.h, hS - 8)
neY = math.max(run.h, hN - 8)
if heightAt(tx + 1, ty) < runTop then -- east flank: hip
seY = math.max(runTop, hS - 8)
neY = math.max(runTop, hN - 8)
end
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
@@ -534,7 +580,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
break
end
end
local row = math.min(ty, front - math.floor(h / 8))
local row = math.min(ty, front - math.floor(hLocal / 8))
if row < north then
-- the whole run folded onto the face: top with the drawn
-- row just above it when that row is furniture too (a
@@ -546,13 +592,27 @@ local function runGeometry(map, bodyOnly, masks, sink)
end
topTile = S.tileAt[keyOf(tx, row)]
end
-- water's surface, and only water's: the recessed sheet itself,
-- never the ground's shoreline bands around it. A cell an object
-- stands on took the branch above and paints synthesized GROUND,
-- which is right -- a sign at the waterline stands on a plot, not
-- on the pond.
topQuad(x0, z0, h, topTile,
s.art == "upright" and VOLUME_TOP_SHADE or 1)
s.art == "upright" and VOLUME_TOP_SHADE or 1,
(s.class == "water") and waterPush or nil)
end
-- sides: 8px bands wherever the neighbour is lower. Band k spans
-- heights [8k, 8k+8) and shows one full tile of art; a partial
-- band crops the art rows to match, so nothing ever stretches.
-- Bands count in CELL-LOCAL height (world minus this cell's base):
-- the fold starts at the cell's own feet wherever the terrain
-- raised them, and the crop a 6px lip face has always worn stays
-- byte-identical on a flat map. Negative bands are the SKIRT a
-- raised cell shows a lower neighbour -- terrain that had no face
-- at all before elevation -- and they wear the cell's own art
-- from its top row down, the same convention the recessed-water
-- shoreline bands established below zero.
for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2])
if nh < h then
@@ -563,9 +623,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(h, band * 8 + 8)
local nl = nh - b
for band = math.floor(nl / 8), math.ceil(hLocal / 8) - 1 do
local ly0 = math.max(nl, band * 8)
local ly1 = math.min(hLocal, band * 8 + 8)
local y0, y1 = b + ly0, b + ly1
if y1 > y0 then
local src, shade = tile, Voxel3D.FACE_SHADE[d]
if run then
@@ -609,7 +671,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
end
end
sideQuad(d, x0, z0, y0, y1, src,
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
sideShades(hl, hr, y0, y1, y0 <= nh, shade))
end
end
@@ -686,7 +748,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
-- the neighbour will ever draw that geometry
if q.own or outwardOnEdge(q, x0, z0, x1, z1)
or keepQuad(x0, z0, x1, z1) then
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade))
local fl = S.base
and baseAt(keyOf(math.floor(q[1][1] / 8),
math.floor(q[1][3] / 8))) or 0
push({ q[1], q[2], q[3], q[4] }, quadUV(q),
groundShades(q, q.shade, fl))
end
end
@@ -730,7 +796,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
for i = 1, 4 do
local c, s2 = q[i], sc[i]
s2[1] = c[1] + mx
s2[2] = c[2]
s2[2] = c[2] + (st.my or 0)
s2[3] = c[3] + mz
end
local ok = keepAll
@@ -742,7 +808,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
ok = keepQuad(x0, z0, x1, z1)
end
if ok then
push(sc, quadUV(q), groundShades(sc, q.shade))
push(sc, quadUV(q), groundShades(sc, q.shade, st.my))
end
end
end
@@ -752,18 +818,34 @@ end
-- The raw geometry for `map`: (vertex list, triangle index list, quad
-- count). Synchronous and GPU-free -- the headless suite and the probes
-- exercise the invariants through this.
function ChunkMesher.geometry(map, bodyOnly, masks)
--
-- `split` lifts the water surface out, as it is lifted out for the
-- reflective pass, and appends that sink's own three values -- so the suite
-- can check the same separation the GPU path relies on without a GPU.
-- Without it the water is in the first list, which is what every existing
-- caller reads.
function ChunkMesher.geometry(map, bodyOnly, masks, split)
local sink = newTableSink()
runGeometry(map, bodyOnly, masks, sink)
return sink.results()
local waterSink = split and newTableSink() or nil
runGeometry(map, bodyOnly, masks, sink, waterSink)
if not waterSink then return sink.results() end
local v, i, n = sink.results()
local wv, wi, wn = waterSink.results()
return v, i, n, wv, wi, wn
end
-- Build the mesh for `map` synchronously. Returns nil when there is
-- nothing to draw or meshes are unavailable (headless).
function ChunkMesher.build(map, bodyOnly, masks)
--
-- `split` asks for the water surface as a SECOND mesh, returned after the
-- terrain one -- the shape the reflective pass needs (see Water). Without
-- it the water is inside the terrain mesh, which is the historical
-- contract and what every other caller still wants.
function ChunkMesher.build(map, bodyOnly, masks, split)
local sink = newSink()
runGeometry(map, bodyOnly, masks, sink)
return sink.finish()
local waterSink = split and newSink() or nil
runGeometry(map, bodyOnly, masks, sink, waterSink)
return sink.finish(), waterSink and waterSink.finish() or nil
end
local function quadsMesh(quads)
@@ -801,6 +883,33 @@ local function buildFlowerMesh(map)
return quadsMesh(Structures.forMap(map).flowerQuads)
end
-- Authored FIGURES (a person drawn into furniture) as one mesh each, in
-- the card's own local space -- because each one is placed by its own
-- matrix at draw time, leaned back by the camera pitch exactly like a
-- character card (VoxelScene). A figure baked into the terrain mesh could
-- not lean, and a shared mesh could not carry per-figure placement.
--
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
-- none, so the loop that draws them is shorter than the terrain's.
local function buildFigureMeshes(map)
local out = {}
for _, f in ipairs(Structures.forMap(map).figures or {}) do
local mesh = quadsMesh(f.quads)
if mesh then
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
end
end
return out
end
-- Figure lists hold their meshes one level down, so the generic slot
-- release cannot reach them.
local function releaseFigures(list)
for _, f in ipairs(type(list) == "table" and list or {}) do
if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end
end
end
-- Replace a cached slot, releasing whatever mesh it held.
local function swapSlot(c, slot, mesh)
local old = c[slot]
@@ -819,12 +928,23 @@ local function entry(id)
return c
end
-- The water surface that came out of a terrain slot's own build. Kept
-- beside it rather than in a slot of its own because the two are ONE
-- answer: a full mesh drawn beside a body build's water would draw the
-- ring's ponds twice and miss the body's own.
local function waterSlot(slot)
return slot .. "Water"
end
local function releaseEntry(c)
for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do
for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater",
"grass", "flowers" }) do
local mesh = c[slot]
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
releaseFigures(c.figures)
c.figures = nil
c.stale = nil
end
@@ -863,28 +983,37 @@ end
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
if c.grass == nil or c.flowers == nil or c.figures == nil
or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
local okF, flowers = pcall(buildFlowerMesh, map)
local okX, figures = pcall(buildFigureMeshes, map)
if (gen[job.id] or 0) ~= job.gen then
if okG and grass and grass.release then pcall(grass.release, grass) end
if okF and flowers and flowers.release then
pcall(flowers.release, flowers)
end
if okX then releaseFigures(figures) end
return
end
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
releaseFigures(c.figures)
c.figures = (okX and figures) or false
if c.stale then c.stale.aux = nil end
end
local sink = newSink()
runGeometry(map, job.slot == "body", job.masks, sink)
local waterSink = newSink()
runGeometry(map, job.slot == "body", job.masks, sink, waterSink)
local mesh = sink.finish()
local water = waterSink.finish()
if (gen[job.id] or 0) ~= job.gen then
if mesh and mesh.release then pcall(mesh.release, mesh) end
if water and water.release then pcall(water.release, water) end
return
end
swapSlot(c, job.slot, mesh or false)
swapSlot(c, waterSlot(job.slot), water or false)
if c.stale then
c.stale[job.slot] = nil
if not (c.stale.full or c.stale.body or c.stale.aux) then
@@ -986,12 +1115,14 @@ function ChunkMesher.get(map, bodyOnly, masks)
if c.stale then c.stale.aux = nil end
end
if c[slot] == nil or (c.stale and c.stale[slot]) then
local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks,
true)
if not ok then
print("[warn] voxel mesh build failed for " .. tostring(map.id)
.. ": " .. tostring(mesh))
end
swapSlot(c, slot, (ok and mesh) or false)
swapSlot(c, waterSlot(slot), (ok and water) or false)
if c.stale then
c.stale[slot] = nil
if not (c.stale.full or c.stale.body or c.stale.aux) then
@@ -1012,6 +1143,21 @@ function ChunkMesher.peek(map, bodyOnly)
return mesh or nil
end
-- A slot's terrain mesh AND the water surface lifted out of it, as one
-- answer. Never builds, like peek.
--
-- Both or neither, always from the SAME slot: the water was cut out of that
-- exact geometry, so pairing a full mesh with a body build's water would
-- draw the border ring's ponds twice and leave the body's as holes. Callers
-- that fall back from one variant to the other fall back through this, so
-- there is nowhere for the two to be chosen separately.
function ChunkMesher.pair(map, bodyOnly)
local c = cache[map.id]
if not c then return nil, nil end
local slot = bodyOnly and "body" or "full"
return c[slot] or nil, c[waterSlot(slot)] or nil
end
function ChunkMesher.grass(map)
local c = cache[map.id]
return c and c.grass or nil
@@ -1022,6 +1168,14 @@ function ChunkMesher.flowers(map)
return c and c.flowers or nil
end
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
-- own leaning matrix at draw time, so they cannot share one mesh.
function ChunkMesher.figures(map)
local c = cache[map.id]
local list = c and c.figures
return (type(list) == "table") and list or nil
end
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
-- replacements cook, and each slot swaps as its build lands. This is
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
+499
View File
@@ -0,0 +1,499 @@
-- Voxel world mode: the day/night cycle -- one clock, and everything the
-- frame asks it.
--
-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
-- the player left is where the cycle picks up. Everything below is a pure
-- function of the clock, so the pinned settings and the running cycle can
-- never drift apart: DUSK is simply the cycle stopped at sunset.
--
-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
-- hanging in the southeast about 45 degrees up. That is the DAY setting and
-- the default, so a player who never touches the row sees the mod they
-- already had. From there the arc swings NORTH at both ends -- rising 70
-- degrees north of east, setting the mirror of that -- because the camera
-- looks north and the northern sky is the only sky it ever frames: a sun
-- that rose due east would light the world for ten minutes without once
-- being seen. Swung north, the disc stands in frame through dawn and dusk
-- (the hours worth looking at) and passes overhead-behind-the-camera
-- through midday, which is where a noon sun belongs.
--
-- THE MOON arcs entirely through the northern sky -- rising northeast, due
-- north at mid-night, setting northwest -- so it hangs over the diorama all
-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
-- softly south, away from it, at about two-thirds the sun's weight.
--
-- SHADOWS are the shear the light throws: direction opposite the body's
-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
-- a long shadow, not an infinite one), strength fading to nothing over the
-- last twelve degrees before the horizon so the handoff between sun and
-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
-- deliberately stays the noon bake: it is a subtle angle term baked into
-- every mesh, and rebaking the world's geometry per phase buys less than
-- the cast shadows, the sky and the tint already say.
--
-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
-- a cave at midnight is exactly as dark as a cave at noon, which is what a
-- room with no windows looks like. Map.isOutdoor is the same test the sky
-- already rests on; the caller passes its answer in (applyRig/tint).
--
-- Persistence: the running cycle's clock is written into the mod's own
-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
-- engine's save.writing event, and read back on save.loaded/created. A save
-- with no clock in it starts at noon.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local PaletteFX = require("src.render.PaletteFX")
local DayNight = {}
-- ------- the dial
DayNight.CYCLE = 1200 -- seconds around the whole dial
DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest
DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight
-- where the pinned settings stop the clock
DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
DayNight.KEY = "daytime"
DayNight.LABEL = "DAYTIME"
-- "sync" first: an unset or unreadable value follows the machine's own
-- clock, per the row's contract (ModSetting values[1] is the default) --
-- and forceSync below reaches for it by the same position.
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
{ "sync", "day", "night", "dusk",
"dawn", "cycle" },
{ "SYNC", "DAY", "NIGHT", "DUSK",
"DAWN", "CYCLE" })
-- The one writer for the FULL pin. While VOXEL sits on FULL the DAYTIME
-- row is off the menu with the rest of the rows the preset owns, and the
-- value is held HERE at SYNC -- the diorama preset's sky follows the clock
-- on the wall, whatever was chosen before. Called from every path that can
-- arrive at or act under FULL (main.lua: the preset itself, the rows hook,
-- the manager's options_changed), mirroring OverworldBattle.forceOG.
function DayNight.forceSync(game)
if DayNight.setting:get() ~= "sync" then
DayNight.setting:setIndex(1, game)
end
end
DayNight.clock = DayNight.T.day -- the running cycle's own position
-- ------- the two arcs
--
-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
-- south), elevations in degrees up from the ground plane.
-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
local NOON_KX, NOON_KZ = -0.85, -0.55
local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
+ NOON_KZ * NOON_KZ)))
local TH_RISE, TH_SET = -70, 250 -- north of east / north of west
local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
local EL_MOON = 40
DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height
DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight
DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press
DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set
-- disc PLACEMENT only: the true elevation would put the noon sun far above
-- any frame, so the arc the discs ride is squashed toward the horizon. The
-- shadows always use the true elevation.
DayNight.ELEV_SQUASH = 0.14
-- three-point arc: a at s=0, b at s=0.5, c at s=1
local function arc(a, b, c, s)
if s < 0.5 then return a + (b - a) * 2 * s end
return b + (c - b) * (2 * s - 1)
end
-- The body lighting the world at clock `t`: bearing and elevation in
-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
-- not the moon rising.
function DayNight.bodyAt(t)
t = t % DayNight.CYCLE
if t <= DayNight.DAY_LEN then
local s = t / DayNight.DAY_LEN
return arc(TH_RISE, TH_NOON, TH_SET, s),
EL_NOON * math.sin(math.pi * s), false
end
local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
return arc(TH_MRISE, TH_MMID, TH_MSET, s),
EL_MOON * math.sin(math.pi * s), true
end
-- The shadow shear that body throws: drift per pixel of height, opposite
-- the bearing, cot(elevation) long, clamped.
function DayNight.shearAt(t)
local th, el, moon = DayNight.bodyAt(t)
if el < 0.5 then el = 0.5 end
local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
end
-- How much shadow the light can press right now, 0..1 of the body's own
-- weight: full up high, gone at the horizon, so sunset hands off to
-- moonrise through a soft shadowless gap instead of snapping.
function DayNight.strengthAt(t)
local _, el = DayNight.bodyAt(t)
local s = el / DayNight.FADE_DEG
if s < 0 then return 0 end
return s < 1 and s or 1
end
-- ------- the palettes
--
-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
-- reads. Six bands, not four: twilight is the whole show here, and six rungs
-- of it is what keeps a sunset reading as a gradient rather than as stripes.
-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
-- after blending, which re-quantises onto it.
-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
-- through. Day's blue horizon and dusk's gold one are near-complements, and
-- a straight lerp between complements bottoms out in grey: mid-transition
-- the whole sky went the colour of dishwater, and gold-to-navy did the same
-- on the far side of sunset. So the evening bends through a golden hour
-- (horizon warming, zenith still blue -- late afternoon), and both edges of
-- the night bend through a violet civil twilight (rose horizon under a
-- violet sky -- the real colour of that half hour).
DayNight.PALETTES = {
day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
{ 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
{ 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
{ 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
{ 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
{ 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
{ 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
}
-- what the world's own colours are multiplied by, per phase (0..255)
DayNight.TINTS = {
day = { 255, 255, 255 },
golden = { 255, 232, 208 },
dawn = { 255, 216, 192 },
dusk = { 255, 192, 168 },
violet = { 184, 160, 200 },
night = { 120, 136, 192 },
}
-- the twilight glow around the low sun, and the discs' own four-shade
-- palettes (lightest first, so a display mode transforms them like any
-- other palette)
DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
{ 248, 144, 80 }, { 216, 96, 64 } }
DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
{ 168, 184, 208 }, { 120, 136, 168 } }
-- The dial as keyframes: a repeated name is a plateau, a change is a
-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
-- waypoint rather than straight across the grey between blue and gold. The
-- morning side needs no waypoint of its own: dawn's pinks into day's blues
-- share a family and blend clean.
local DIAL
local function dial()
if DIAL then return DIAL end
local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
DIAL = {
{ 0, "dawn" }, { B, "day" },
{ D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
{ D + B / 2, "violet" }, { D + B, "night" },
{ C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
}
return DIAL
end
-- Phase weights at clock `t`, off the dial above.
function DayNight.mix(t)
t = t % DayNight.CYCLE
local d = dial()
for i = 1, #d - 1 do
local a, b = d[i], d[i + 1]
if t >= a[1] and t < b[1] then
if a[2] == b[2] then return { [a[2]] = 1 } end
local u = (t - a[1]) / (b[1] - a[1])
return { [a[2]] = 1 - u, [b[2]] = u }
end
end
return { dawn = 1 }
end
-- back onto the 5-bit lattice after any blend
local function q8(v)
v = math.floor(v / 8 + 0.5) * 8
if v < 0 then return 0 end
return v > 248 and 248 or v
end
local function blend3(key, mix, fallback)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = key[name] or fallback
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
return { q8(r), q8(g), q8(b) }
end
-- The sky palette for clock `t`, blended between the phase palettes and
-- re-quantised to the lattice. Memoised per whole second: the answer only
-- moves as the cycle runs, and the cycle moves it slowly.
local palCache = { key = nil, pal = nil }
function DayNight.palette(t)
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if palCache.key == key then return palCache.pal end
local mix = DayNight.mix(t)
local pal = {}
for i = 1, #DayNight.PALETTES.day do
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.PALETTES[name][i]
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
pal[i] = { q8(r), q8(g), q8(b) }
end
palCache.key, palCache.pal = key, pal
return pal
end
-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
-- caller answers for where it is standing (see the header).
local tintCache = { key = nil, tint = nil }
local NEUTRAL = { 1, 1, 1 }
function DayNight.tint(outdoor, t)
if not outdoor then return NEUTRAL end
t = t or DayNight.time()
local key = math.floor(t % DayNight.CYCLE)
if tintCache.key ~= key then
-- NOT re-quantised: this is a light level the shader multiplies by, not
-- a palette colour, and the lattice's 248 ceiling would make even noon
-- fractionally dim
local mix = DayNight.mix(t)
local r, g, b = 0, 0, 0
for name, w in pairs(mix) do
local c = DayNight.TINTS[name] or DayNight.TINTS.day
r = r + c[1] * w
g = g + c[2] * w
b = b + c[3] * w
end
tintCache.key = key
tintCache.tint = { r / 255, g / 255, b / 255 }
end
return tintCache.tint
end
-- The twilight glow: how strongly (0..1) and in what colour the sky warms
-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
function DayNight.glow(t)
t = t or DayNight.time()
local _, _, moon = DayNight.bodyAt(t)
if moon then return 0, nil end
local mix = DayNight.mix(t)
local amt = (mix.dawn or 0) + (mix.dusk or 0)
if amt <= 0 then return 0, nil end
return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
end
-- ------- the clock itself
local lastMode = nil
local function mode()
return DayNight.setting:get() or "day"
end
-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
-- fraction. A named seam rather than a bare os.date call, so the suite can
-- hand it a fixed hour.
function DayNight.hours()
local d = os.date("*t")
return d.hour + d.min / 60 + d.sec / 3600
end
-- SYNC: the machine's own time of day laid onto the dial. Local noon is
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
-- falls when the player's does.
function DayNight.syncTime()
return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
end
-- The effective time: the pin, the running clock under CYCLE, or the wall
-- clock under SYNC.
function DayNight.time()
local m = mode()
if m == "cycle" then return DayNight.clock end
if m == "sync" then return DayNight.syncTime() end
return DayNight.T[m] or DayNight.T.day
end
-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
-- (which ticks through battles and menus too, so night falls during a long
-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
-- the pin the player was just looking at: DUSK then CYCLE rolls on into
-- night rather than teleporting the sky.
function DayNight.update(dt)
local m = mode()
if m ~= lastMode then
if m == "cycle" then
-- from a pin, its time; from SYNC, wherever the real sky already was
DayNight.clock = DayNight.T[lastMode]
or (lastMode == "sync" and DayNight.syncTime())
or DayNight.clock
end
lastMode = m
end
if m == "cycle" and dt and dt > 0 then
DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
end
end
-- The clock the RIG runs on: quantised, so the shadow map redraws a few
-- times a minute as the sun crawls rather than every frame.
DayNight.STEP = 2
function DayNight.rigTime()
local t = DayNight.time()
return math.floor(t / DayNight.STEP) * DayNight.STEP
end
-- ------- what the frame reads
-- Point the shared light rig at the clock -- or at noon, indoors. This
-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
-- sunDark uniform), so no draw path changes to follow the sun; they follow
-- the rig, and the rig follows the clock.
function DayNight.applyRig(outdoor)
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local t = outdoor and DayNight.rigTime() or DayNight.T.day
local kx, kz, moon = DayNight.shearAt(t)
ShadowMap.KX, ShadowMap.KZ = kx, kz
Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
return t
end
-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
-- caller that sets its own alpha (the battle arena) and should still lose
-- its shadows to a sunset.
function DayNight.shadowScale(outdoor, t)
if not outdoor then return 1 end
t = t or DayNight.rigTime()
local _, _, moon = DayNight.bodyAt(t)
local s = DayNight.strengthAt(t)
return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
end
-- The disc to hang in the sky, or nil when the body is set or behind the
-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
-- is; the caller projects it through its own camera.
function DayNight.body(t)
t = t or DayNight.time()
local th, el, moon = DayNight.bodyAt(t)
if el < -2 then return nil end
local e = math.rad(el * DayNight.ELEV_SQUASH)
local b = math.rad(th)
return {
dx = math.cos(b) * math.cos(e),
dy = math.sin(e),
dz = math.sin(b) * math.cos(e),
moon = moon,
}
end
-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun
-- or moon to see, so the shadow rig stays the mod's fixed noon light,
-- which is all that ever filtered through the leaves -- but not a sealed
-- room either: night still FALLS in them. Of everything the clock does,
-- exactly one thing reaches a canopy map: the hour's tint.
DayNight.CANOPY = { VIRIDIAN_FOREST = true }
function DayNight.isCanopy(map)
return (map and map.id and DayNight.CANOPY[map.id]) and true or false
end
-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky.
-- They come on through dusk (a lit window against a sunset is half the point
-- of having either), burn all night, and are mostly out again by dawn:
-- people wake before it is bright, they do not read at sunrise.
local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 }
function DayNight.windowLight(t)
local mix = DayNight.mix(t or DayNight.time())
local lit = 0
for name, w in pairs(mix) do
lit = lit + (LAMPS[name] or 0) * w
end
return lit
end
-- The period name for the engine's world.tod hook (map.palette ctx.tod,
-- music.select): the dominant phase, in the vocabulary day/night mods use.
local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
function DayNight.tod(t)
local mix = DayNight.mix(t or DayNight.time())
local best, bestW = "day", -1
for name, w in pairs(mix) do
if w > bestW then best, bestW = name, w end
end
return TOD[best] or "DAY"
end
-- ------- persistence
--
-- The clock rides the SAVE SLOT, not the options file: what time it is in
-- Kanto is a fact about that journey, like where the player is standing.
-- mod.save is the loader's per-mod bucket in save.modData, which persists
-- with the slot on its own -- writing the value is all there is to do.
DayNight.SAVE_KEY = "clock"
function DayNight.store()
local saveApi = V.mod and V.mod.save
if not (saveApi and saveApi.set) then return end
pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
end
function DayNight.restore()
local saveApi = V.mod and V.mod.save
local stored = nil
if saveApi and saveApi.get then
local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
if ok then stored = got end
end
-- no time set: it is day (the requirement, verbatim)
DayNight.clock = type(stored) == "number"
and stored % DayNight.CYCLE or DayNight.T.day
end
return DayNight
+169
View File
@@ -0,0 +1,169 @@
-- The hour's light on the FLAT world.
--
-- The clock already reaches everything the 3D pass draws: VoxelScene and
-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
-- mode off and none of that happens -- the tint is a uniform in a shader the
-- flat tile path never runs -- so the same evening that fell on the diorama
-- left the 2D world at permanent noon. One clock, two worlds, one of them
-- ignoring it.
--
-- So the flat composite gets the same multiply, painted as one rectangle.
--
-- ------- WHERE, which is the only difficult part
--
-- Not on the world canvas. In a colorized mode that canvas is grayscale art
-- and the blit that puts it on screen runs it through the palette shader,
-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
-- night blue over it first and every shade lands in the wrong bucket -- the
-- world would not darken, it would change colour into whatever the palette
-- said the wrong bucket was.
--
-- So it goes on AFTER that pass, on the composited world. And not after the
-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
-- the HUD are paper held up in front of the world rather than part of it --
-- the same reason the tilt-shift blur is a worldPresent and not a present.
--
-- Which leaves one instant: between the world blit and the UI blit, inside
-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
-- hook for exactly this, only runs when a PIPELINE produced the world, which
-- in flat mode is the one thing that did not happen. So endFrame is wrapped
-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
-- by identity rather than by counting or guessing.
--
-- The shader and scissor that call arrives under belong to the UI blit already
-- in progress, so both are put aside for the rectangle and handed straight
-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
--
-- ------- WHEN
--
-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
-- load-bearing:
--
-- the flat path a pipeline that rendered the world already applied the
-- tint inside its own shader; painting it again would apply
-- the hour twice. worldOverride is exactly "a pipeline drew
-- this frame".
-- outdoors a room has no sky to take its light from, which is the
-- same answer DayNight.tint gives on its own and the same
-- one applyRig gives the sun.
-- not neutral midday is a multiply by white. Skipped rather than drawn,
-- so a game with the clock at DAY issues not one extra call.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local DayNight = V.require("DayNight")
local DayTint = {}
-- Below this the tint is close enough to white that the rectangle would not
-- change a pixel, and the frame is left exactly as it was.
DayTint.NEUTRAL = 0.999
local function outdoorNow()
local ok, Game = pcall(require, "src.core.Game")
if not ok then return false end
local ow = Game and Game.overworld
local map = ow and ow.map
if not map then return false end
local okMap, Map = pcall(require, "src.world.Map")
if not okMap then return false end
local outdoor = map.def and Map.isOutdoor(map.def) or false
-- a canopy floor takes the hour's colour and nothing else of it, exactly as
-- it does in the 3D pass (BattleScene, VoxelScene)
return outdoor or DayNight.isCanopy(map)
end
-- The colour this frame's world should be multiplied by, or nil to leave the
-- frame alone.
function DayTint.forFrame(renderer)
if not renderer then return nil end
if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
if not renderer.worldActive then return nil end -- no world on screen at all
if not outdoorNow() then return nil end
local tint = DayNight.tint(true)
if not tint then return nil end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
return nil
end
return r, g, b
end
-- One rectangle over the window, multiplied into whatever is under it.
--
-- The whole window rather than the world's own rect, which is what the
-- engine's warp fade does from the same place and for the same reason: the
-- border fill, the letterbox bars and the world are all "the world" as far as
-- the hour is concerned, and black multiplied by anything is still black.
-- Every read of the graphics state is optional, because a headless driver
-- ships some of these and not others -- the same reason TerrainAtlas reads the
-- engine's seams guarded. What cannot be read cannot be put back either, and a
-- missing accessor must cost the tint rather than the frame.
local function saved(name, ...)
local fn = love.graphics[name]
if not fn then return nil end
local ok, a, b, c, d = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d
end
function DayTint.paint(r, g, b)
local gfx = love.graphics
local shader = saved("getShader")
local sx, sy, sw, sh = saved("getScissor")
local blend, alpha = saved("getBlendMode")
local pr, pg, pb, pa = saved("getColor")
local w, h = gfx.getDimensions()
if gfx.setShader then gfx.setShader() end
if gfx.setScissor then gfx.setScissor() end
gfx.setBlendMode("multiply", "premultiplied")
gfx.setColor(r, g, b, 1)
gfx.rectangle("fill", 0, 0, w, h)
gfx.setBlendMode(blend or "alpha", alpha)
gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
if gfx.setScissor then
if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
end
if shader and gfx.setShader then gfx.setShader(shader) end
end
function DayTint.install()
local Renderer = require("src.render.Renderer")
if Renderer.dramaticShapeTintHook then return end
local inner = Renderer.endFrame
function Renderer:endFrame(zones, worldZones)
local r, g, b = DayTint.forFrame(self)
if not r then return inner(self, zones, worldZones) end
local gfx = love.graphics
local draw = gfx.draw
local ui = self.canvas
local painted = false
gfx.draw = function(tex, ...)
-- the UI canvas reaching the screen: the world is finished, the paper
-- in front of it has not started. Restored FIRST so the rectangle's own
-- drawing cannot re-enter this, and so a UI blit that draws one quad per
-- SGB zone only triggers it once.
if not painted and tex == ui then
painted = true
gfx.draw = draw
DayTint.paint(r, g, b)
end
return draw(tex, ...)
end
local ok, err = pcall(inner, self, zones, worldZones)
gfx.draw = draw
if not ok then error(err, 0) end
end
Renderer.dramaticShapeTintHook = true
end
return DayTint
+174
View File
@@ -0,0 +1,174 @@
-- Voxel world mode: the glass in the windows, found rather than listed.
--
-- Buildings and doors in the overworld art carry small panes -- six texels
-- wide, framed in black, with a diagonal shine drawn in. This module finds
-- them by SHAPE in the tileset image itself: a border row of six black
-- texels, four or five rows of black-flanked non-black glass under it, and
-- a closing border row. No tile ids are hardcoded, so a total conversion
-- that draws its own windows in the same idiom gets glass for free, and art
-- with no windows gets an empty mask and costs nothing.
--
-- The scan slides at PIXEL granularity because the art does: the building
-- window sits a row down inside its tile, and the door's pane straddles a
-- 2x2 tile block entirely -- a per-tile matcher finds neither.
--
-- What the scan yields is a MASK TEXTURE the same size as the tileset
-- atlas: opaque white on glass texels, transparent everywhere else. Terrain
-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect),
-- so the scene shader can sample this mask with the SAME coordinates and
-- know, per fragment, whether it is drawing glass -- on any wall, at any
-- angle, in free-roam or a staged battle, with no geometry work anywhere.
-- The recoloured atlases (display modes, RED++) keep the tileset's layout,
-- so the alignment holds under every palette.
--
-- What the shader does with the answer (Voxel3D): by day a thin glint
-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving
-- the art under it -- and after dark the panes are LIT: the texel's own
-- shine pattern, warmed and brightened, exempt from the sun, the shadow
-- map and the hour's tint, as a window with a lamp behind it is.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local GlassMask = {}
-- pane geometry the scan accepts: six glass texels across, and this many
-- rows of them between the two border rows
GlassMask.GLASS_W = 6
GlassMask.ROWS = { 4, 5 } -- door pane, building pane
-- Whether a channel triple is the border black. The raw tileset art is the
-- four DMG greys, so black is genuinely zero; the threshold forgives a
-- rescaled asset without accepting the dark grey rung (85/255 = 0.33).
local function isBlack(r, g, b)
return r < 0.12 and g < 0.12 and b < 0.12
end
GlassMask._isBlack = isBlack -- named for the suite
-- Find every pane in an image, through a pure reader so the geometry is
-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based
-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels
-- (the border is the detector's evidence, not part of the answer).
function GlassMask.scan(getPixel, w, h)
local function black(x, y)
return isBlack(getPixel(x, y))
end
local function borderRow(x, y)
for c = 1, 6 do
if not black(x + c, y) then return false end
end
return true
end
local function glassRow(x, y)
if not (black(x, y) and black(x + 7, y)) then return false end
for c = 1, 6 do
if black(x + c, y) then return false end
end
return true
end
local want = {}
for _, n in ipairs(GlassMask.ROWS) do want[n] = true end
local rects = {}
for y = 0, h - 1 do
for x = 0, w - 8 do
if borderRow(x, y) then
local n = 0
while y + 1 + n < h and glassRow(x, y + 1 + n) do
n = n + 1
end
if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then
rects[#rects + 1] = { x = x + 1, y = y + 1,
w = GlassMask.GLASS_W, h = n }
end
end
end
end
return rects
end
-- ------- the runtime cache, one entry per tileset image
local cache = {} -- image path -> { rects, texture (or false) }
local function entry(tileset)
local path = tileset and tileset.image
if not path then return nil end
local hit = cache[path]
if hit then return hit end
local ok, data = pcall(Assets.imageData, path)
if not (ok and data) then
-- unreadable art is a verdict for the session, not a retry loop
cache[path] = { rects = {}, texture = false }
return cache[path]
end
local w, h = data:getDimensions()
local rects = GlassMask.scan(function(x, y)
return data:getPixel(x, y)
end, w, h)
local texture = false
if #rects > 0 and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage then
local built = pcall(function()
local mask = love.image.newImageData(w, h)
for _, r in ipairs(rects) do
for yy = r.y, r.y + r.h - 1 do
for xx = r.x, r.x + r.w - 1 do
mask:setPixel(xx, yy, 1, 1, 1, 1)
end
end
end
texture = love.graphics.newImage(mask)
texture:setFilter("nearest", "nearest")
end)
if not built then texture = false end
end
cache[path] = { rects = rects, texture = texture }
return cache[path]
end
-- The panes found in a tileset's art, as glass rects in atlas pixels.
function GlassMask.rects(tileset)
local e = entry(tileset)
return e and e.rects or {}
end
-- The mask texture for a tileset, or nil when it has no panes (or the art
-- is unreadable, or there is no GPU) -- callers bind the blank instead.
function GlassMask.texture(tileset)
local e = entry(tileset)
return (e and e.texture) or nil
end
-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask:
-- the scene shader always declares the sampler, and an unbound sampler is
-- a driver-dependent crash rather than a fallback.
local blank = nil
function GlassMask.blank()
if blank == nil then
local ok, img = pcall(function()
local data = love.image.newImageData(1, 1)
data:setPixel(0, 0, 0, 0, 0, 0)
return love.graphics.newImage(data)
end)
blank = (ok and img) or false
end
return blank or nil
end
-- Drop the GPU objects (window resize, hot reload). The rects survive --
-- they are a fact about the art -- but textures are rebuilt on demand.
function GlassMask.invalidate()
for _, e in pairs(cache) do
if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end
e.texture = false
end
cache = {}
blank = nil
end
return GlassMask
+65
View File
@@ -0,0 +1,65 @@
-- Voxel world mode: decoded pixels, kept.
--
-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads
-- resolve the same way but stay uncached: the caller keeps the derived
-- product" (src/render/Assets.lua) -- which is the right contract for the
-- flat renderer, whose one caller decodes a strip once and keeps the strip.
--
-- This mod is not that caller. It reads the same handful of images over and
-- over, from several places that do not know about each other:
--
-- * the tileset atlas, decoded by Structures (its own cache), by
-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by
-- TerrainAtlas again to learn a tile's shades, and by GlassMask;
-- * the FLOWER FRAME files, decoded inside patch() -- which runs every
-- time the animation step turns over, about three times a second, for
-- as long as the map is on screen. That one is not a load cost at all,
-- it is a recurring per-second cost on the render thread, and it was
-- the single clearest waste the first profile turned up.
--
-- So: one table, keyed by the path as the CALLER gave it, holding the
-- decoded ImageData. Registered with Assets.invalidate so a hot reload
-- drops it alongside every other downstream cache.
--
-- The entries are never evicted by size. That is deliberate and bounded:
-- what lands here is tileset art and animation frames -- a few dozen small
-- images for a whole session, tens of kilobytes each -- not per-map bakes,
-- which have their own eviction in TerrainAtlas.setLive.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Perf = V.require("Perf")
local ImageCache = {}
local cache = {}
-- The decoded pixels for `path`, or nil when it cannot be read.
--
-- `false` is cached for an unreadable path, so a missing or corrupt asset
-- costs one failed decode for the session rather than one per frame -- the
-- same sticky-failure shape the rest of this mod uses for GPU objects.
function ImageCache.get(path)
if not path then return nil end
local hit = cache[path]
if hit ~= nil then
Perf.count("imageCache.hit")
return hit or nil
end
local t0 = Perf.now()
local ok, data = pcall(Assets.imageData, path)
Perf.add("ImageCache.decode", t0)
Perf.count("imageCache.miss")
cache[path] = (ok and data) or false
return cache[path] or nil
end
function ImageCache.invalidate()
cache = {}
end
Assets.register(ImageCache.invalidate)
return ImageCache
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
-- Voxel world mode: the instrumentation core.
--
-- Ships DARK. Every entry point is one boolean test away from doing
-- nothing, and the boolean is false unless a run explicitly asks for
-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the
-- save directory for a device that has no environment to set). A mod that
-- measures itself in every player's session is a mod that costs every
-- player the measurement, so the default has to be off and the off path
-- has to be free.
--
-- What it measures, and why those three things:
--
-- * LABELS -- named spans (a bake, a mesh build, a shader compile),
-- accumulated as {n, total, max}. `max` is the one that matters: a
-- bake that costs 40ms ONCE is a visible hitch, and an average hides
-- it completely.
-- * FRAMES -- a ring of the last N whole-frame times, stamped once per
-- rendered frame. Frame time is the only number the player actually
-- experiences; every label total is a hypothesis about which frames.
-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas
-- rebakes). Cheaper than a span when the question is "how often",
-- not "how long".
--
-- Spans are wall time, and on a GPU that means submission time, not
-- completion time -- the driver is free to finish the work later. So a
-- GPU-side saving shows up in the FRAME numbers rather than in the label
-- for the pass that caused it, and both are reported.
local Perf = {}
local clock = (love and love.timer and love.timer.getTime) or os.clock
-- Read through pcall: the loader's sandbox does not hand a mod `os`, and
-- instrumentation must never be the reason the mod fails to load. Same
-- shape as OverworldBattle's DS_BATTLE_DEBUG probe.
local function envFlag(name)
local ok, value = pcall(function() return os.getenv(name) end)
if not ok then return nil end
if value == nil or value == "" or value == "0" then return nil end
return value
end
local function flagFile()
if not (love and love.filesystem and love.filesystem.getInfo) then
return false
end
local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag")
return ok and info ~= nil
end
Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile()
Perf.labels = {} -- label -> { n, total, max }
Perf.order = {} -- insertion order, so a report reads chronologically
Perf.counters = {} -- name -> integer
Perf.frames = {} -- ring of frame times, seconds
Perf.frameCount = 0
Perf.RING = 4096
-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark
-- names the phase it is driving; every frame and every label span
-- recorded while that name is set is attributed to it, which is what
-- turns "the walk was slow" into "the walk was slow ONLY on the frames
-- right after ROUTE_1 came into view".
Perf.segment = nil
Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} }
local function segmentEntry()
local name = Perf.segment
if not name then return nil end
local s = Perf.segments[name]
if not s then
s = { name = name, frames = {}, labels = {}, order = {} }
Perf.segments[name] = s
Perf.segments[#Perf.segments + 1] = s -- array half preserves order
end
return s
end
function Perf.setSegment(name)
Perf.segment = name
if name then segmentEntry() end
end
-- ---------------------------------------------------------------- spans
--
-- Call shape at the measured site:
--
-- local t0 = Perf.now()
-- ... the work ...
-- Perf.add("TerrainAtlas.staticAtlas", t0)
--
-- When disabled, now() returns nil and add() returns on the nil -- two
-- function calls and a branch, no table touched, no string built. Sites
-- that would run thousands of times a frame (per draw call, per vertex)
-- are still too hot for that and are deliberately NOT instrumented; the
-- frame ring covers them in aggregate.
function Perf.now()
if not Perf.enabled then return nil end
return clock()
end
local function bump(store, order, label, dt)
local s = store[label]
if not s then
s = { n = 0, total = 0, max = 0 }
store[label] = s
order[#order + 1] = label
end
s.n = s.n + 1
s.total = s.total + dt
if dt > s.max then s.max = dt end
end
function Perf.add(label, t0)
if t0 == nil then return end
local dt = clock() - t0
bump(Perf.labels, Perf.order, label, dt)
local seg = segmentEntry()
if seg then bump(seg.labels, seg.order, label, dt) end
end
-- Wrap a function in a table, in place. Used by drivers to instrument
-- module internals they do not own; the mod's own code calls now()/add()
-- directly so the label is visible at the site.
function Perf.wrap(tbl, name, label)
local orig = tbl and tbl[name]
if not orig then return false end
tbl[name] = function(...)
if not Perf.enabled then return orig(...) end
local t0 = clock()
local a, b, c, d = orig(...)
Perf.add(label or name, t0)
return a, b, c, d
end
return true
end
-- ------------------------------------------------------------- counters
function Perf.count(name, by)
if not Perf.enabled then return end
Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1)
end
-- --------------------------------------------------------------- frames
--
-- Called once per RENDERED frame (the endFrame seam), not once per
-- logic update: a scripted run can step the game many times per render,
-- and a frame the player never saw cannot have hitched for them.
local lastFrame = nil
function Perf.frame()
if not Perf.enabled then return end
local t = clock()
if lastFrame then
local dt = t - lastFrame
local n = Perf.frameCount + 1
Perf.frameCount = n
Perf.frames[(n - 1) % Perf.RING + 1] = dt
local seg = segmentEntry()
if seg then seg.frames[#seg.frames + 1] = dt end
end
lastFrame = t
end
-- Discard the pending frame stamp: after a long blocking operation the
-- next frame delta would include it and libel the renderer.
function Perf.resync()
lastFrame = Perf.enabled and clock() or nil
end
-- ------------------------------------------------------------ reporting
local function percentile(sorted, p)
local n = #sorted
if n == 0 then return 0 end
local i = math.ceil(p * n)
if i < 1 then i = 1 end
if i > n then i = n end
return sorted[i]
end
-- Frame statistics in MILLISECONDS. p95/p99 rather than the average
-- because smoothness is a tail property: a run that averages 9ms and
-- spikes to 60ms four times reads as stuttering, and its average reads
-- as fine.
function Perf.frameStats(list)
local src = list or Perf.frames
local sorted = {}
for i = 1, #src do sorted[i] = src[i] * 1000 end
table.sort(sorted)
local n = #sorted
local total = 0
for i = 1, n do total = total + sorted[i] end
local over16, over33 = 0, 0
for i = 1, n do
if sorted[i] > 16.7 then over16 = over16 + 1 end
if sorted[i] > 33.3 then over33 = over33 + 1 end
end
return {
n = n,
avg = n > 0 and total / n or 0,
p50 = percentile(sorted, 0.50),
p95 = percentile(sorted, 0.95),
p99 = percentile(sorted, 0.99),
worst = n > 0 and sorted[n] or 0,
over16 = over16,
over33 = over33,
}
end
function Perf.reset()
Perf.labels, Perf.order = {}, {}
Perf.counters = {}
Perf.frames, Perf.frameCount = {}, 0
Perf.segments = {}
Perf.segment = nil
lastFrame = nil
end
local function sortedLabels(store, order)
local out = {}
for _, lbl in ipairs(order) do out[#out + 1] = lbl end
table.sort(out, function(a, b) return store[a].total > store[b].total end)
return out
end
function Perf.printReport(title)
print(("[perf] ==== %s ===="):format(tostring(title or "report")))
local f = Perf.frameStats()
print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d")
:format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33))
for _, seg in ipairs(Perf.segments) do
local s = Perf.frameStats(seg.frames)
print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d")
:format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33))
end
print("[perf] ---- labels (ms, sorted by total) ----")
for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do
local s = Perf.labels[lbl]
print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f")
:format(lbl, s.n, s.total * 1000, s.max * 1000))
end
local names = {}
for k in pairs(Perf.counters) do names[#names + 1] = k end
table.sort(names)
if #names > 0 then print("[perf] ---- counters ----") end
for _, k in ipairs(names) do
print(("[perf] %-46s %d"):format(k, Perf.counters[k]))
end
end
-- ------------------------------------------------------------------ json
--
-- Hand-rolled rather than pulled from the engine: the report has to be
-- readable by a diff tool between two runs, and that means stable key
-- ORDER, which a generic serializer does not promise.
local function q(s)
return '"' .. tostring(s):gsub('[%c"\\]', function(c)
if c == '"' then return '\\"' end
if c == "\\" then return "\\\\" end
return ("\\u%04x"):format(c:byte())
end) .. '"'
end
local function num(x)
return ("%.4f"):format(x)
end
local function statsJson(f)
return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}")
:format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99),
num(f.worst), f.over16, f.over33)
end
local function labelsJson(store, order)
local parts = {}
for _, lbl in ipairs(sortedLabels(store, order)) do
local s = store[lbl]
parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}")
:format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000))
end
return "{" .. table.concat(parts, ",") .. "}"
end
function Perf.toJson(meta)
local parts = {}
parts[#parts + 1] = "{"
parts[#parts + 1] = "\"meta\":{"
local m = {}
for k, v in pairs(meta or {}) do
m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v))
end
table.sort(m)
parts[#parts + 1] = table.concat(m, ",") .. "},"
parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. ","
parts[#parts + 1] = "\"segments\":{"
local segs = {}
for _, seg in ipairs(Perf.segments) do
segs[#segs + 1] = q(seg.name) .. ":{\"frames\":"
.. statsJson(Perf.frameStats(seg.frames))
.. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}"
end
parts[#parts + 1] = table.concat(segs, ",") .. "},"
parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. ","
local cs = {}
for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end
table.sort(cs)
parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}"
parts[#parts + 1] = "}"
return table.concat(parts, "")
end
-- Written through love.filesystem (the save directory) rather than io:
-- a driver run and an Android session both have one, and neither is
-- guaranteed a writable working directory.
function Perf.write(name, meta)
local body = Perf.toJson(meta)
if love and love.filesystem then
pcall(love.filesystem.createDirectory, "ds_bench")
local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body)
if ok then
print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory())
.. "/ds_bench/" .. name .. ".json")
return true
end
end
print("[perf] JSON " .. name .. ": " .. body)
return false
end
-- ----------------------------------------------------------- draw stats
--
-- love.graphics.getStats() resets per frame, so it is only meaningful
-- read at the END of a frame -- which is where Perf.frame() runs.
function Perf.drawStats()
if not (love and love.graphics and love.graphics.getStats) then return end
local s = love.graphics.getStats()
Perf.count("stat.drawcalls", s.drawcalls or 0)
Perf.count("stat.canvasswitches", s.canvasswitches or 0)
Perf.count("stat.shaderswitches", s.shaderswitches or 0)
Perf.count("stat.frames", 1)
Perf.texturememory = s.texturememory
Perf.canvases = s.canvases
Perf.images = s.images
end
return Perf
+124 -6
View File
@@ -29,6 +29,7 @@ local V = ...
local Mat4 = V.require("Mat4")
local Voxel = V.require("VoxelState")
local Elevation = V.require("Elevation")
local ShadowMap = {}
@@ -77,7 +78,44 @@ ShadowMap.HEIGHT = 160
-- surface shadows itself in a moire of acne; too much and a shadow detaches
-- from the foot of what casts it. The frustum is ~400 world pixels deep and
-- the packed depth resolves under 0.01 of one, so there is room.
ShadowMap.BIAS = 1.0
--
-- It cannot be ONE number, because what the comparison has to forgive is
-- not fixed: the map stores one depth for a whole texel, so a lit surface
-- reads its own depth wrong by however far it RAMPS across that texel --
-- the texel's world size times the surface's slope in the light's frame.
-- The texel swings from a third of a world pixel at the closest zoom to
-- well over one at a maximised window on the widest, so a constant bias is
-- generous at one end of the ladder and short at the other. Short shows up
-- as diagonal bands of acne across big lit surfaces -- diagonal because
-- the moire runs along neither the world grid nor the screen's, but along
-- the depth ramp in the sun's own frame, and the sun sits southeast.
--
-- So: a floor for what does not scale (the packed depth's quantisation,
-- and the two passes reaching the same world point by different matrices),
-- plus a term in texels for what does.
ShadowMap.BIAS = 0.5
-- World pixels of slack per world pixel of texel, for the steepest LIT
-- surface here: a roof pitched 45 degrees and turned away from the sun,
-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of
-- the light frame's two axes (a vertical wall, by comparison, manages 1.7,
-- flat ground 0.7, and anything steeper than that roof has its back to the
-- sun and never reads the map at all). The 2x2 filter's taps sit half a
-- texel out on both axes at once, so the worst a tap can disagree by is
-- half the ramp along each -- which is where the halving that turns 6.2
-- into 3.1 comes from, and why it is the SUM of the two components rather
-- than their magnitude.
--
-- Measured against the artefact rather than trusted: the probe
-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit
-- surfaces, and the banding stops at slack ~2.4 world px on the widest
-- rung -- where this lands 3.1 * 0.83 + 0.5.
ShadowMap.SLOPE = 3.1
-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel.
-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth
-- the map actually stores.
ShadowMap.slack = ShadowMap.BIAS
local SHADER = [[
varying float vDepth;
@@ -93,17 +131,23 @@ local SHADER = [[
}
#endif
#ifdef PIXEL
uniform float sprite; // 1 while the CAST is being drawn; see ShadowMap.sprites
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
// the same alpha discard the main pass uses: a sprite card casts its
// silhouette, not its 16x16 bounding box
if (Texel(tex, tc).a < 0.5) discard;
// pack into two channels: the high byte in red, the low in green
// pack into two channels: the high byte in red, the low in green.
// Blue says WHAT cast this, which costs a channel that was zero anyway
// and lets a surface decline one kind of caster -- water does, for the
// people (see Water's sunLit).
float d = clamp(vDepth, 0.0, 1.0) * 255.0;
return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0);
return vec4(floor(d) / 255.0, fract(d), sprite, 1.0);
}
#endif
]]
ShadowMap._source = function() return SHADER end -- named for the suite
local shader = nil -- nil = untried, false = unavailable
local canvas = nil -- nil = untried, false = unavailable
local canvasRes = 0 -- the edge `canvas` was made at
@@ -252,7 +296,10 @@ local function fit(cx, cy, vw, vh)
local f = sunDir()
local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 })
local reach = ShadowMap.HEIGHT
-- the tallest thing that can cast: the fixed geometry ceiling plus the
-- terrain base it may be standing on (0 wherever no elevation solved)
local top = ShadowMap.HEIGHT + Elevation.maxBase()
local reach = top
* math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24
local north = groundReach(vh)
-- the view widens with distance, so the far ground spans more than the
@@ -260,7 +307,7 @@ local function fit(cx, cy, vw, vh)
-- frustum's true spread and costs a good deal less resolution
local spread = north * 0.5
local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach }
local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water
local ys = { -32, top } -- -32 covers recessed water
local zs = { cy - north, cy + vh / 2 + reach }
local l, r, b, t, zn, zf
@@ -316,9 +363,58 @@ local function fit(cx, cy, vw, vh)
-- what the frustum ended up covering, for probes: the lateral extent in
-- world pixels divided by RES is how fine a shadow edge can land
ShadowMap.extent = { r - l, t - b, far - near }
-- the slack the comparison needs, against the coarser of the two texel
-- axes (the box is asymmetric, and one number has to cover both)
ShadowMap.slack = ShadowMap.BIAS
+ ShadowMap.SLOPE * math.max(w, h) / res
-- the stored depth spans the frustum, so a world-pixel bias is that
-- fraction of it
ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near)
ShadowMap.bias = ShadowMap.slack / math.max(1, far - near)
end
-- How much of the compare's forgiveness a snugged caster takes back, 0..1.
-- Short of 1 on purpose: at exactly 1 the card's own fragments compare
-- against their own stored depth on a float-equality knife edge and can
-- speckle. The tenth left over is dozens of times the packed depth's
-- quantization -- ample for that -- and leaves the contact gap around a
-- quarter of a world pixel at any sun, which no zoom resolves.
ShadowMap.SNUG = 0.9
-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is
-- drawn into the map.
--
-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term)
-- so lit surfaces do not acne against their own texels -- but that same
-- forgiveness is what lets the ground right next to a standing figure read
-- as lit: a receiver within `slack` of its blocker along the ray passes the
-- test, so the first stretch of every shadow is forgiven away and on screen
-- it starts that far from the feet, further the lower the sun. The classic
-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at
-- a day/night golden hour or under the moon.
--
-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow
-- falls -- every point stays on the same light ray -- but moving it toward
-- the sun stores it SHALLOWER, so a ground point right at the foot is
-- already `slack` deeper than the stored blocker and fails the lit test:
-- the root lands back under the feet. Nothing else is touched -- no
-- terrain moved, so the acne margin the slack exists for is intact where
-- it matters. For sprite cards and other thin stand-ins only.
--
-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same
-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`).
-- Stored and lookup then agree exactly, as they did before snugging, and
-- the compare keeps its full acne margin. A caster stored snugged but read
-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal
-- moire bands crawling across the card.
--
-- Valid between begin() and the next begin(): `slack` and the sun hold
-- still between redraws of the map, so a lit frame that reuses last
-- frame's map computes the same displacement it was stored with.
function ShadowMap.snug(model)
local f = sunDir()
local s = -ShadowMap.slack * ShadowMap.SNUG
return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s),
model or IDENTITY)
end
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
@@ -354,6 +450,9 @@ function ShadowMap.begin(cx, cy, vw, vh)
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
-- the world until a cast pass says otherwise, reset per pass so one that
-- forgot to put it back cannot leak into the next map's terrain
pcall(sh.send, sh, "sprite", 0)
drawing = true
ready = false
return true
@@ -362,6 +461,25 @@ end
-- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
-- pull, which is a trick for the VIEW's depth buffer and would drag a
-- shadow off whatever throws it.
-- Whether what is drawn next is one of the CAST -- a walker, an authored
-- figure, a battle's Pokemon -- rather than part of the world. false for the
-- length of such a pass, true to put it back.
--
-- The map records it per texel (the shader's blue channel) so a surface can
-- decline that kind of caster, and exactly one does: water. A character
-- standing at a lake's edge threw a hard cut-out of its own sprite across
-- the surface, which on something showing the sky and the shoreline reads as
-- a sticker rather than as a shadow in the water. Everything else -- ground,
-- roofs, ledges, the characters themselves -- still takes them.
--
-- Sent rather than branched, so a caller that forgets to put it back only
-- mislabels casters rather than losing them; begin() resets it per pass.
function ShadowMap.sprites(on)
if not drawing then return end
local sh = getShader()
if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end
end
function ShadowMap.draw(mesh, texture, model)
if not (drawing and mesh) then return end
local sh = getShader()
+518
View File
@@ -0,0 +1,518 @@
-- The sky, generated rather than shipped.
--
-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void
-- behind it is sky rather than a black plate: at 75 degrees the horizon is
-- genuinely in frame and the bands run down to meet it, and at the steeper rungs
-- the void that shows is the ground running out past the map edge, which gets
-- the same sky above the same haze. A battle's placed camera keeps the flat fill
-- it has always had -- its horizon is above the frame and its look is not this
-- rung's to change.
--
-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat
-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band
-- dithered into the bottom of each one. Alternating two colours on a pixel grid
-- is how a machine with four colours to a palette got a fifth, sixth and seventh
-- out of them, and it is what keeps four bands reading as a gradient rather than
-- as four stripes. No clouds, nothing moving.
--
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
-- back up: one full-region rectangle through a shader that answers every pixel
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
-- displayed at, so there is nothing for a filter to soften and nothing to go
-- stale when the window or the zoom changes. The shader does bind one texture,
-- but it is a palette rather than an image -- the bands, one texel each, sampled
-- nearest (see rampFor, and why it is not a uniform array).
--
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band
-- edges land on the same grid the world's own texels do, and a ZOOM keypress is
-- reflected in the frame that follows it rather than whenever something else
-- happened to rebuild.
--
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
-- is shade order: a display mode transforms a four-colour palette by replacing it
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
-- written light to dark. So the sky reads the list backwards -- deepest shade
-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
-- nothing.
--
-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
-- (four phase palettes, blended along the clock and re-quantised to the
-- lattice), and this file paints whatever the clock says: blue at noon, gold
-- and violet through the twilights -- warmed further around the low sun by a
-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
-- hang here too: cell-art discs on the same grid as the dither, scissored to
-- the sky's own region so a setting body slips below the horizon point and is
-- gone, never wandering under the map.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Sky = {}
-- The most bands a phase palette may paint with. Eight leaves headroom over
-- DayNight's six-band ones without paying for more; the ramp the shader reads
-- them from is built at the width actually used, so the cap costs nothing.
Sky.MAX_BANDS = 8
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
-- leaves the top of each band flat -- a band dithered all the way through reads
-- as one averaged colour instead of as a step with a soft bottom edge.
Sky.DITHER = true
Sky.DITHER_START = 0.6
-- How much of the frame the bands cover when the horizon is NOT in it, as a
-- fraction of the canvas height.
--
-- At the steeper rungs the camera looks down far enough that the ground plane's
-- vanishing line is above the top edge -- there is no horizon to hang the pale
-- end on, but there is still void up there where the map runs out, and it should
-- read as sky. So the bands take the same slice of the frame the top rung's own
-- horizon gives them, which keeps the sky looking like one sky across the whole
-- ladder instead of changing character rung by rung.
Sky.SPAN = 0.23
-- ------- the bands
--
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
--
-- Memoised, because this runs once a frame and the answer only moves when the
-- mode does.
local cache = { bands = nil, key = {}, ramp = nil }
function Sky.bands()
local pal = DayNight.palette()
local shades = PaletteFX.effectiveColors(pal) or pal
local n = math.min(#shades, #pal, Sky.MAX_BANDS)
local key, k = cache.key, 0
local same = cache.bands ~= nil and #cache.bands == n
for i = 1, n do
local c = shades[i]
for ch = 1, 3 do
k = k + 1
if key[k] ~= c[ch] then same = false end
key[k] = c[ch]
end
end
if same then return cache.bands end
-- the ramp is these bands as a texture (see rampFor); a new list is a new
-- ramp, and the old one is nothing's to keep
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
local bands = {}
for i = 1, n do
-- backwards: the palette's darkest rung is the top band
local c = shades[n - i + 1]
bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
cache.bands = bands
return bands
end
-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
-- bottom edge and the right flat fill for any outdoor void that wants to
-- match the clock without painting bands (the battle arena's backdrop).
function Sky.haze()
local bands = Sky.bands()
return bands and bands[#bands] or nil
end
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
-- making it the bottom band's own colour means the haze below the sky and the
-- bottom of the sky are one colour -- the join has no seam, and a frame that
-- cannot paint the bands is a hazy sky rather than a wrong one.
--
-- Mutates the descriptor, which is a fresh table per frame from its caller.
function Sky.dress(sky)
local bands = Sky.bands()
local haze = bands and bands[#bands]
if not (sky and haze) then return sky end
sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3]
sky.bands = bands
return sky
end
-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon
-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil
-- when there is no room for any of it.
function Sky.region(h, horizonY)
if not (h and h > 0) then return nil end
local edge = horizonY
if not (edge and edge > 0) then edge = h * Sky.SPAN end
edge = math.min(edge, h)
if edge < 1 then return nil end
return edge
end
-- ------- the pass
--
-- One rectangle, one shader. Every pixel answers for itself from its canvas
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
-- -- there is no image being scaled and so nothing to be soft. The one texture
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
-- sampled nearest, one lookup per pixel (see rampFor).
--
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
-- by is the top of its own cell row, so a whole cell row is one colour and every
-- edge in the sky lands on the diorama's pixel grid.
local SHADER_SRC = [[
uniform Image ramp; // the bands, one texel each, top of the sky first
uniform float count; // how many texels wide that ramp is
uniform float edge; // the sky's bottom, in canvas pixels
uniform float cell; // the diorama's pixel size, in canvas pixels
uniform float start; // where the checker begins inside a band
uniform float alpha;
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
uniform vec2 glowPos; // the sun disc, in canvas pixels
uniform float glowInvR; // 1 / the glow's reach
uniform vec3 glowColor;
// Band `i`, read from its own texel centre. The index is clamped rather than
// trusted: `pos` below can land exactly on `count` when the arithmetic is
// carried at mediump -- which is the fragment default on GLSL ES -- and a
// sample past the last band must be the last band, not whatever is off the
// end of the image.
vec3 bandAt(float i) {
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float row = floor(sc.y / cell) * cell; // top of this cell row
float pos = min(row / max(edge, 1.0), 1.0) * count;
float base = min(floor(pos), count - 1.0);
vec3 c = bandAt(base);
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (base < count - 1.0 && (pos - base) > start) {
if (parity < 0.5) { c = bandAt(base + 1.0); }
}
// The sunset's warmth, radiating from the disc: posterised to a few rungs
// and checker-dithered between them -- the same 8-bit move as the bands,
// so the glow reads as painted light rather than as a smooth airbrush --
// and measured cell-to-cell, so its rings ride the diorama's own grid.
if (glowAmt > 0.0) {
vec2 cc = (floor(sc / cell) + 0.5) * cell;
float d = length(cc - glowPos) * glowInvR;
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
float lvl = floor(g * 4.0);
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
}
return vec4(c, alpha);
}
]]
-- ------- the ramp
--
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
-- read through a loop counter. On desktop GL that is as portable as it looks.
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
-- strip running from partway down the gradient to the horizon point, with the
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
-- palest band again, but delivered by love.graphics.clear instead of by the
-- array) landing in exactly the right colour. Same colour, two routes, one of
-- them black: the fault was the array, not the palette.
--
-- Which of the ES failure modes it was hardly matters -- a driver that
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
-- because they all have the same shape: slots past the first few read as zero,
-- and zero is black.
--
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
-- there is no array to index and no budget to overrun, and a texel that does
-- not exist cannot read as black because the image is built at exactly the
-- width the shader divides by. Nearest and clamped, so a sample lands on one
-- band's own colour and an out-of-range one lands on the end band rather than
-- on nothing.
--
-- Rebuilt only when the bands move, which is when the clock or the display
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
local function rampFor(bands)
if cache.ramp and cache.rampFor == bands then return cache.ramp end
if not (love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then return nil end
local n = #bands
if n < 1 then return nil end
local ok, data = pcall(love.image.newImageData, n, 1)
if not (ok and data) then return nil end
for i = 1, n do
local c = bands[i]
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
end
local built, img = pcall(love.graphics.newImage, data)
if not (built and img) then return nil end
-- nearest: a band is a flat colour, not something to interpolate between.
-- clamp: the shader clamps its index too, so this is the second of two
-- guards against ever sampling off the end -- and it returns the edge band.
pcall(img.setFilter, img, "nearest", "nearest")
pcall(img.setWrap, img, "clamp", "clamp")
cache.ramp, cache.rampFor = img, bands
return img
end
Sky._rampFor = rampFor -- named for the suite
-- The band ramp for the CURRENT bands, plus how many texels wide it is --
-- for a pass that wants to read the same sky this one paints. The water's
-- reflection is the one caller: it looks the reflected direction up on this
-- very ramp, so the sky on the lake and the sky over it are one palette,
-- through one display-mode transform, off one clock.
--
-- nil where the ramp could not be built, which is exactly when Sky.paint
-- falls back to flat bands -- so a driver that loses the gradient loses the
-- reflected gradient with it rather than showing two different skies.
function Sky.ramp()
local bands = Sky.bands()
if not (bands and bands[1]) then return nil end
local img = rampFor(bands)
if not img then return nil end
return img, #bands, bands
end
-- How far the twilight glow reaches around the disc, in canvas pixels, for
-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`.
Sky.GLOW_REACH = 0.55
local shader = nil -- nil = untried, false = unavailable
local function getShader()
if shader == nil then
shader = false
if love.graphics and love.graphics.newShader then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
if ok and sh then
shader = sh
elseif V and V.mod and V.mod.log then
-- once, and only where it can be read: the fallback below is a sky
-- without its dither, which is easy to look at and impossible to
-- diagnose without this line
V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, "
.. "with no dither between them", tostring(sh))
end
end
end
return shader or nil
end
Sky._getShader = getShader -- named for the suite
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
-- quantised edges. For a driver that could not compile the shader -- which is
-- also every headless run.
local function paintFlat(w, h, bands, edge, alpha, cell)
local g = love.graphics
local n = #bands
local prev = 0
for i = 1, n do
local cut = (i == n) and math.min(h, math.ceil(edge))
or math.floor(i / n * edge / cell + 0.5) * cell
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
if cut > prev then
local c = bands[i]
g.setColor(c[1], c[2], c[3], alpha)
g.rectangle("fill", 0, prev, w, cut - prev)
end
prev = cut
end
end
-- ------- the discs
--
-- The sun and moon, as cell art: a circle of whole diorama cells with a
-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
-- through the same display-mode transform as every palette here, and
-- SCISSORED to the sky's region: the horizon point is where a setting body
-- disappears, so it can never hang under the map at a high pitch.
--
-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
-- an angle, so zooming the ground in and out must not swell and shrink the
-- sun with it. The radius is a fraction of the frame height, converted to
-- whole cells so the disc still sits on the diorama's grid -- chunky cells
-- up close, fine ones at survey zoom, the same size body either way.
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
-- crater centres as fractions of the radius, so they ride any disc size.
-- Public because the water's reflection draws the same moon (see Water):
-- one list, so the disc on the lake cannot drift from the one in the sky.
Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
{ -0.15, 0.7 }, { 0.05, 0.05 } }
-- a crater's radius, as a fraction of the disc's -- the r/5 paintDisc uses
Sky.CRATER_FRAC = 0.2
local MOON_CRATERS = Sky.MOON_CRATERS
-- The disc's four shades as the display mode has them, lightest first.
-- Shared with the reflection pass, so the sun on the water is the same sun
-- that is in the sky, in the same mode's palette.
function Sky.discShades(moon)
local src = moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
return PaletteFX.effectiveColors(src) or src
end
-- Whether this body is the LOOMING low sun -- the sunset exaggeration.
local function looming(body)
return (body.glowAmt or 0) > 0.25 and not body.moon
end
-- The disc's radius for a `h`-tall frame on a `cell`-pixel grid: in CANVAS
-- PIXELS, and in whole cells. Sized by the FRAME rather than by the world
-- (see DISC_FRAC), so a zoom does not swell the sun.
--
-- Read by paintDisc below and by the reflection, which needs the same
-- number in radians -- a disc drawn one size and mirrored another would
-- read as two different suns.
function Sky.discRadius(h, cell, body)
cell = math.max(1, cell or 1)
local r = math.max(Sky.DISC_MIN,
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
if body and looming(body) then r = r + math.max(1, math.floor(r * 0.4)) end
return r * cell, r
end
local function paintDisc(body, edge, cell, w, h)
local g = love.graphics
if not (body and body.y and g.setScissor) then return end
local shades = Sky.discShades(body.moon)
local twilight = looming(body)
local _, r = Sky.discRadius(h, cell, body)
-- snap the centre to the cell grid, like everything else in this sky
local bx = math.floor(body.x / cell) * cell + cell / 2
local by = math.floor(body.y / cell) * cell + cell / 2
if by - r * cell > edge then return end -- wholly below the horizon point
local core = shades[1]
local main = shades[twilight and 3 or 2]
local sx, sy, sw, sh = g.getScissor()
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
local craterR = math.max(1, math.floor(r / 5))
for dy = -r, r do
for dx = -r, r do
local d = math.sqrt(dx * dx + dy * dy)
if d <= r + 0.1 then
local c = d <= r * 0.5 and core or main
-- dithered rim: the outer ring keeps only one parity of its cells
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
if body.moon then
for _, cr in ipairs(MOON_CRATERS) do
local cdx = dx - math.floor(cr[1] * r + 0.5)
local cdy = dy - math.floor(cr[2] * r + 0.5)
if cdx * cdx + cdy * cdy <= craterR * craterR then
c = shades[3]
end
end
end
if keep then
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
g.rectangle("fill", bx + dx * cell - cell / 2,
by + dy * cell - cell / 2, cell, cell)
end
end
end
end
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
g.setColor(1, 1, 1, 1)
end
-- Paint the sky into the bound canvas, filling it from the top edge down to
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
--
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
--
-- `body` is the sun or moon to hang, already projected to canvas pixels by
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
-- along; nil hangs nothing and warms nothing.
--
-- Returns false when there is nothing to paint, in which case the caller's flat
-- fill is the whole sky. That fill is the palest band, so a frame that declines
-- this looks like a hazy day rather than like a bug.
function Sky.paint(w, h, sky, horizonY, cell, body)
local bands = sky and sky.bands
if not (bands and bands[1]) then return false end
if not (w and h and w > 0 and h > 0) then return false end
local g = love.graphics
if not (g and g.rectangle) then return false end
local edge = Sky.region(h, horizonY)
if not edge then return false end
local alpha = sky[4] or 1
cell = math.max(1, math.floor((cell or 1) + 0.5))
-- State to put aside. The scene's shader is one, and the blend mode another --
-- a pass that left "replace" behind would make the fade-in strength meaningless
-- -- but the DEPTH MODE is the one that would break the frame: a rectangle
-- drawn under the pass's own ("lequal", true) stamps itself across the depth
-- buffer at the near plane and hides the entire world behind the sky.
local prevShader = g.getShader and g.getShader() or nil
local cmp, write
if g.getDepthMode then cmp, write = g.getDepthMode() end
if g.setDepthMode then g.setDepthMode("always", false) end
local blend, blendAlpha
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
if g.setBlendMode then g.setBlendMode("alpha") end
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
local sh = getShader()
local ramp = sh and rampFor(bands)
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
if sh then
local sent = pcall(function()
-- the bands arrive as a texture, one texel each, and `count` is that
-- texture's width -- see rampFor for why they are not a uniform array
sh:send("ramp", ramp)
sh:send("count", #bands)
sh:send("edge", edge)
sh:send("cell", cell)
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
sh:send("alpha", alpha)
sh:send("glowAmt", glowAmt)
if glowAmt > 0 then
local gc = body.glowColor or { 248, 224, 168 }
sh:send("glowPos", { body.x, body.y })
sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
end
end)
if sent then
g.setShader(sh)
g.setColor(1, 1, 1, 1)
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
g.setShader()
else
sh = nil
end
end
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
-- the disc goes over the glow, under nothing: plain rectangles, so it is
-- there whether or not the shader built
paintDisc(body, math.min(h, edge), cell, w, h)
g.setColor(1, 1, 1, 1)
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end
if prevShader and g.setShader then g.setShader(prevShader) end
return true
end
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
-- context builds a new one instead of drawing with a handle from the old. The
-- ramp is a GPU object on the same context and goes with it.
function Sky.invalidate()
shader = nil
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
cache.ramp, cache.rampFor = nil, nil
end
return Sky
+313 -42
View File
@@ -49,12 +49,21 @@ local Map = require("src.world.Map")
local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Budget = V.require("BuildBudget")
local Elevation = V.require("Elevation")
local Structures = {}
-- must match ChunkMesher's ring (3 border blocks, in tiles)
local RING = 12
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery. (Declared up here rather than
-- beside buildCylinders because forMap's grid resolve reads it too.)
local ROUND_RING = 4
-- object-mode gates
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
@@ -154,12 +163,34 @@ function Structures.forMap(map)
local TileRenderer = require("src.render.TileRenderer")
local borderId = TileRenderer.borderBlockFor(map)
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
-- TREES fill stops at ROUND_RING instead of running the full RING.
-- Only that far out does a tree cell get carved into a hull; past it
-- the cells fall through to the mesher's plain box, and a slab of
-- flat-topped boxes beside the modelled wall reads as a painted-on
-- plateau -- the wall looking like it was cut off with scissors. So
-- the far ring is simply not built: beyond ROUND_RING tileLookup
-- answers nil, which is the same "nothing out there" BLACK already
-- produces and every pass below already copes with. The cut lands on
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
-- of 4 tiles, so no group is left half-resolved at the edge.
--
-- WATER and the other tilesets' own borders keep the full ring: a flat
-- sheet of water is what water looks like from above anyway, and an
-- interior's border is black already.
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
and (TileRenderer.voidFill or "trees") == "trees"
local tw2, th2 = tw, th
local function tileLookup(tx, ty)
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
return map:tileAt(tx, ty)
end
if not borderBlk then return nil end
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
or tx >= tw2 + ROUND_RING
or ty >= th2 + ROUND_RING) then
return nil
end
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
end
local shapeAt, tileAt = {}, {}
@@ -178,6 +209,38 @@ function Structures.forMap(map)
end
end
-- ---- the terrain's base elevation under every tile ----
--
-- Elevation solves the ledge-bounded terrain once, globally, at CELL
-- granularity (see lib/Elevation.lua); here it lands per TILE so the
-- mesher and every quad emitter below read one table. Two wrinkles:
--
-- * a ledge tile's box KEEPS its authored height but sinks by it --
-- base + h then puts the lip's top flush with the high plateau it
-- is the rim of, and its exposed south face is exactly the tier
-- drop wearing the same cropped lip art it always wore;
-- * ring tiles read through baseAtTile's edge clamp, so the border
-- apron continues the body's elevation instead of cliffing to 0.
--
-- Maps without a field (every interior) get no table at all, and every
-- consumer's `S.base and ...` guard keeps the classic flat path.
local base = nil
if Elevation.fieldFor(map.id) then
base = {}
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local k = keyOf(tx, ty)
if tileAt[k] then
local b = Elevation.baseAtTile(map, tx, ty)
local s = shapeAt[k]
if s and s.class == "ledge" then b = b - (s.h or 0) end
if b ~= 0 then base[k] = b end
end
end
end
end
-- ---- buildings: whole sprites voxelized band by band ----
--
-- Before anything else looks at this grid. A profiled building is a
@@ -192,8 +255,9 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil, base = base,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {} }
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -268,6 +332,14 @@ function Structures.forMap(map)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
@@ -880,13 +952,6 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
return quads, bg
end
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery.
local ROUND_RING = 4
-- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set):
-- the same four-tile tree repeats for hundreds of cells on a map and
-- across every route of its tileset, so the carve runs once per distinct
@@ -970,7 +1035,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
r = 16 }
r = 16, my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
end
for dy = 0, 3 do
for dx = 0, 3 do
@@ -1003,7 +1068,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
end
ground = tpl.bg or false
S.roundStamps[#S.roundStamps + 1] =
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8,
my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
end
-- headless (no pixels): no hull, but still claim the tiles so
-- the volume path never boxes a pinned cell. Ground is the
@@ -1103,6 +1169,8 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
local quads = S.objectQuads
local wx0, wz0 = region.minX * 8, region.minY * 8
-- a relief lies ON the terrain, so the whole slab rides the region's base
local by = S.base and S.base[keyOf(region.minX, region.minY)] or 0
for py = 0, bh - 1 do
for px = 0, bw - 1 do
if on(px, py) then
@@ -1110,26 +1178,27 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
local u = (srcU[i] + 0.5) / atlasW
local v = (srcV[i] + 0.5) / atlasH
local x, z = wx0 + px, wz0 + py
local y0, y1 = by, by + h
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.top)
quad({ x, y1, z }, { x + 1, y1, z }, { x + 1, y1, z + 1 },
{ x, y1, z + 1 }, RELIEF_SHADE.top)
if not on(px, py + 1) then
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
{ x, h, z + 1 }, RELIEF_SHADE.south)
quad({ x, y0, z + 1 }, { x + 1, y0, z + 1 }, { x + 1, y1, z + 1 },
{ x, y1, z + 1 }, RELIEF_SHADE.south)
end
if not on(px, py - 1) then
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
{ x + 1, h, z }, RELIEF_SHADE.north)
quad({ x + 1, y0, z }, { x, y0, z }, { x, y1, z },
{ x + 1, y1, z }, RELIEF_SHADE.north)
end
if not on(px - 1, py) then
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
{ x, h, z }, RELIEF_SHADE.side)
quad({ x, y0, z }, { x, y0, z + 1 }, { x, y1, z + 1 },
{ x, y1, z }, RELIEF_SHADE.side)
end
if not on(px + 1, py) then
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
quad({ x + 1, y0, z + 1 }, { x + 1, y0, z }, { x + 1, y1, z },
{ x + 1, y1, z + 1 }, RELIEF_SHADE.side)
end
end
end
@@ -1176,10 +1245,11 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
return ns ~= nil and ns.art == "bookcase"
end
local by = S.base and S.base[keyOf(tx, frontTy)] or 0
for band = 0, bands - 1 do
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
local u0, u1, v0, v1 = uvRect(tile)
local y0, y1 = band * 8, band * 8 + 8
local y0, y1 = by + band * 8, by + band * 8 + 8
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
{ x1, y1, z1 }, { x0, y1, z1 },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
@@ -1214,6 +1284,9 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
end
function Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
-- Read once: it is a property of the tileset, not of the column.
local backfill = TileShape.bookcaseBackfill(map.tileset.id)
for tx = x0, x1 do
local ty = y1
while ty >= y0 do
@@ -1241,10 +1314,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1)
capTile = S.tileAt[ck]
end
end
-- The box is one cell deep, so it covers only the run's southmost
-- rows; everything north of that is vacated. By default a vacated
-- row is skipped and painted with synthesized ground -- right for a
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
-- the cell above the run instead, shape and art, so a wall cut into
-- a terrace has more terrace behind it rather than a trench.
local covered = math.min(2, front - top + 1)
local srcK = keyOf(tx, top - 1)
local src = backfill == "above" and S.shapeAt[srcK] or nil
for cy = top, front do
local tk = keyOf(tx, cy)
S.skip[tk] = true
S.ground[tk] = false
if src and cy <= front - covered then
S.shapeAt[tk] = src
S.tileAt[tk] = S.tileAt[srcK]
else
S.skip[tk] = true
S.ground[tk] = false
end
end
bookcaseRank(S, map, tx, top, front, capTile)
front = top - 1
@@ -1281,6 +1368,7 @@ local function stairCell(S, map, data, cx, cy, s)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
local q0 = #quads
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
local east = s.class == "stair_e" or s.class == "stair_down_e"
local mx, mz = cx * 16, cy * 16
@@ -1417,6 +1505,16 @@ local function stairCell(S, map, data, cx, cy, s)
end
end
end
-- the whole flight was built from y=0; a raised base lifts it after
-- the fact so the geometry above stays in the cell's own space
local by = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0
if by ~= 0 then
for i = q0 + 1, #quads do
local q = quads[i]
for c = 1, 4 do q[c][2] = q[c][2] + by end
end
end
end
function Structures.buildStairs(S, map, x0, x1, y0, y1)
@@ -1700,18 +1798,44 @@ function Structures.extractObjects(S, map, region, data, perRow, force)
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
strict = fs ~= nil and fs.class == "cutout"
end
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
-- prop whose body reaches its own edge votes itself out. The Center's
-- potted plants are the case: the pot's olive base is drawn flush on the
-- bottom row of the block, so "dark" came back as background and every
-- dark pixel in the whole plant drained with it -- the pots rendered as
-- hollow black frames while the 2D art has solid olive bodies.
--
-- Where the vote misreads the art, the profile can name the background
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
-- per tileset, because the answer is per drawing: the healing consoles'
-- screens really do stand on a dark wall band and really do need dark
-- voted out, and the PC really does need light kept.
local bg = {}
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
do
local named = TileShape.propBg(map.tileset.id)
if named then
for _, c in ipairs(region.tiles) do
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
if rule then
for shadeName in pairs(rule) do bg[shadeName] = true end
break
end
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
if not next(bg) then
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
end
for i, st in pairs(state) do
if strict then
if st == "dark" or st == "light" then
@@ -1943,11 +2067,18 @@ function Structures.buildObject(S, map, region, cluster,
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
math.floor(cluster.maxY / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
-- `bookcase` supports as well as `upright`. A prop drawn above an
-- authored box stands ON it whatever art the box renders with, and a
-- stacked box is still a box: the Plateau's gate pilasters carry a
-- statue on 48 of their tops, and collapsing the pilaster to a stacked
-- run made every one of them fail this test and drop to ground level.
if blocked and bs and bs.authored and (bs.h or 0) > 0
and (bs.art == "upright" or bs.art == "bookcase") then
baseY, support = bs.h, bs
end
end
-- and whatever it stands on, it stands on it at the terrain's base
baseY = baseY + (S.base and S.base[keyOf(cluster.minX, cluster.maxY)] or 0)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
@@ -2078,7 +2209,8 @@ function Structures.buildObject(S, map, region, cluster,
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
if support and support.class == "wall" then
if support and (support.class == "wall" or support.class == "cliff"
or support.art == "bookcase") then
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
-- plinth) is a statue on a pillar with ONE cell of footprint: the
-- block below already carries the whole base, so the drawn cell
@@ -2086,6 +2218,13 @@ function Structures.buildObject(S, map, region, cluster,
-- the base backwards. Furniture supports (a monitor on its desk)
-- keep the box-extension below -- their drawn cell is the
-- furniture's own upper rows, and floor there would amputate it.
--
-- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
-- full-height blocks like `wall` and belong here, while `desk` is
-- 24px and still furniture. The Plateau's statues on stacked
-- pilasters found this -- taking the furniture branch turned each
-- statue's own two rows into a 32px box wearing the pilaster's art,
-- so every one of them stood inside a slab of its own plinth.
S.skip[k] = true
S.ground[k] = best
elseif support then
@@ -2116,6 +2255,136 @@ function Structures.buildObject(S, map, region, cluster,
return true
end
-- ---- figures: a person drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position.
--
-- The mask IS the classification: no flood, no shade segmentation, no
-- validation gate. Every automatic route in this file asks the art where
-- the object ends, and a figure painted into its own furniture has no
-- answer to give -- so the profile answers instead, and this only has to
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
--
-- A figure is a SPRITE, not a prop. It gets exactly the treatment
-- SpriteBillboards gives a character: one flat plane of the drawing's own
-- pixels, no thickness, standing at its feet and leaned back by the
-- camera's pitch at draw time so it always reads face-on -- because that
-- is what the artwork is. A seated man drawn face-on is a 2D icon like
-- every other Gen 1 figure; extruding him into a slab reconstructs a body
-- nobody drew (the ten-voxel version read as a wedge of furniture, and
-- even one voxel showed an edge the sprites never show).
--
-- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
-- from. One quad per pixel rather than one alpha-keyed texture: the
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
-- same silhouette straight out of the live atlas, so every palette bake
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return fig.mask[ly * bw + lx] or false
end
-- his feet and his west edge: the card's own origin
local lowY, minX = 0, bw - 1
for ly = 0, bh - 1 do
for lx = 0, bw - 1 do
if at(lx, ly) then
if ly > lowY then lowY = ly end
if lx < minX then minX = lx end
end
end
end
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one.
local baseY = 0
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY = bs.h
end
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = fig.tiles[math.floor(ly / 8) * fig.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = lx - minX, lowY - ly
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
u = u, v = v, shade = 1 }
end
end
end
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet are
-- drawn in, which is the same convention a character card uses (its feet
-- plane sits at its cell's middle) -- so he sorts against the couch and
-- against a player walking past exactly the way an NPC standing there
-- would.
S.figures[#S.figures + 1] = {
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY + (S.base and S.base[keyOf(tx, ty + fig.h - 1)] or 0),
}
-- What each covered tile wears now that he is off it. Only the ART
-- changes: the couch tiles keep their `counter` box (they ARE the
-- couch) and the floor tiles he overhung stay flat floor -- the
-- profile just names the version of each drawing without him in it,
-- so nothing has to be synthesized or repainted from a neighbour vote.
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
end
end
-- Every authored figure, wherever the map draws it.
--
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
-- places this couch once in each of the eleven Pokemon Centers (and the
-- Celadon Hotel), so the pattern finds all of them without the profile
-- naming a single map or cell. The repaint above replaces the pattern's
-- own tiles, so a match can never fire twice on the same drawing.
function Structures.buildFigures(S, map, x0, x1, y0, y1)
local figures = TileShape.figures(map.tileset.id)
if not figures then return end
local perRow = map.tileset.tilesPerRow or 16
for _, fig in ipairs(figures) do
for ty = y0, y1 - fig.h + 1 do
for tx = x0, x1 - fig.w + 1 do
Budget.tick()
local hit = true
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
hit = false
break
end
end
if hit then buildFigure(S, map, fig, tx, ty, perRow) end
end
end
end
end
-- ---- tall grass ----
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
@@ -2218,12 +2487,13 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
@@ -2417,12 +2687,13 @@ function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
templates[tileId] = tpl
end
local wx, wz = tx * 8, ty * 8
local wy = S.base and S.base[k] or 0
for _, q in ipairs(tpl) do
quads[#quads + 1] = {
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
uv = q.uv, shade = q.shade,
}
end
+6 -1
View File
@@ -270,7 +270,12 @@ local function readback(image)
local prev = love.graphics.getCanvas()
local ok, data = pcall(function()
local w, h = image:getDimensions()
local canvas = love.graphics.newCanvas(w, h)
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
-- so the atlas would be drawn into a texture 2.75x its size and read
-- back magnified -- and every tile coordinate below, which counts in
-- eights from the top-left, would land somewhere between two tiles.
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
-- straight copy: no blending against the cleared target, no tint from
+189 -17
View File
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
sign = 12,
wall = 16,
tree = 16,
-- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
-- badge-check gates down Route 23 are drawn 32px, the same height as a
-- statue on its plinth, and read as a step in the terrain rather than a
-- room's wall. Same fold as `wall`, twice the height -- and its own
-- class because `wall` is 16px for every interior in the game.
cliff = 32,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
@@ -110,6 +116,7 @@ local ART = {
ledge = "top",
roof = "top",
wall = "upright",
cliff = "upright",
tree = "upright",
fence = "upright",
sign = "upright",
@@ -168,6 +175,8 @@ local ART = {
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
local figCache = {} -- tileset id -> parsed figure masks, or false
local bgCache = {} -- tileset id -> prop background shades, or false
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
-- through the mod's own file loader rather than package.path: a mod's
@@ -224,29 +233,42 @@ end
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
-- the map and coordinates are in hand. First match wins; no match keeps
-- the tile's ordinary pin.
-- `when_below` is the mirror, and it exists because ABOVE is not always the
-- side that tells the two uses apart. The Plateau's $0D is the case: it is
-- the gate wall's top band AND the base course under a column of rock face,
-- and scanned over both maps the tile above is $03 for 64 of the first and
-- 140 of the second -- no rule on `above` can split them. What is BELOW
-- does, exactly: the wall's own face $0F sits under the top band and under
-- nothing else (336 vs 352, clean).
local function authoredConditions(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local spec = entry and entry.when_above
if type(spec) ~= "table" then return nil end
if type(entry) ~= "table" then return nil end
local out, any = {}, false
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule.above) == "table" then
local set = {}
for _, t in ipairs(rule.above) do set[t] = true end
list[#list + 1] = { above = set, class = rule.class }
local function collect(spec, side)
if type(spec) ~= "table" then return end
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = out[tile] or {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule[side]) == "table" then
local set = {}
for _, t in ipairs(rule[side]) do set[t] = true end
list[#list + 1] = { side = side, set = set, class = rule.class }
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
end
collect(entry.when_above, "above")
collect(entry.when_below, "below")
return any and out or nil
end
@@ -348,9 +370,12 @@ function TileShape.at(map, shapes, tile, tx, ty)
-- tile and the cell rules below (see authoredConditions)
local rules = shapes.cond and shapes.cond[tile]
if rules then
local above = map:tileAt(tx, ty - 1)
for _, rule in ipairs(rules) do
if above and rule.above[above] then
-- NOTE map:tileAt border-EXTENDS: one row off an edge answers the
-- map's borderBlock, never nil. A rule listing whatever that block
-- draws will fire along that whole edge (it did, on the Marts).
local n = map:tileAt(tx, rule.side == "above" and ty - 1 or ty + 1)
if n and rule.set[n] then
-- shapes.condShape, NOT shapes.classes: the canonical class
-- shapes are SHARED, and `wall` in particular is the very object
-- rule 4 hands every unauthored solid tile. Marking that one
@@ -369,11 +394,158 @@ function TileShape.at(map, shapes, tile, tx, ty)
return s
end
-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture,
-- cut out by an explicit pixel mask and stood up on top of it.
--
-- Every other route in this file resolves a whole 8x8 TILE, which is
-- exactly why none of them can reach a figure that shares its tiles with
-- the thing it sits on -- and the detector's segmentation cannot either
-- when the drawing has no background margin to flood from and wears the
-- same shades as its furniture. So the profile authors the silhouette
-- pixel by pixel (see data/voxel_heights.lua):
--
-- figures = { { w = <tiles across>,
-- tiles = { ...w*h tile ids, row-major... },
-- under = { ...w*h ids: what each tile wears once the
-- figure is lifted off it... },
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
-- figure... } } }
--
-- No class: a figure is always a flat sprite card, drawn the way
-- SpriteBillboards draws a character (see Structures.buildFigures).
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
function TileShape.figures(tilesetId)
local hit = figCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.figures
local out = {}
if type(list) == "table" then
for _, f in ipairs(list) do
local ok = type(f) == "table" and type(f.w) == "number"
and type(f.tiles) == "table" and type(f.under) == "table"
and type(f.pixels) == "table"
local w = ok and math.floor(f.w) or 0
local h = (w >= 1) and (#f.tiles / w) or 0
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
and #f.under == #f.tiles and #f.pixels == h * 8
if ok then
for i = 1, h * 8 do
local row = f.pixels[i]
if type(row) ~= "string" or #row ~= w * 8 then
ok = false
break
end
end
end
if ok then
local mask, n = {}, 0
for ly = 0, h * 8 - 1 do
local row = f.pixels[ly + 1]
for lx = 0, w * 8 - 1 do
if row:sub(lx + 1, lx + 1) ~= "." then
mask[ly * (w * 8) + lx] = true
n = n + 1
end
end
end
if n > 0 then
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
end
end
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
--
-- Structures normally votes on this by reading the shades that touch the
-- drawing's own bounding box, which is right whenever the drawing has a
-- margin of floor around it and wrong when it does not: a prop whose body
-- reaches its own edge votes itself out. Naming the shades is the override,
-- and it is keyed by TILE because the answer is per drawing rather than per
-- tileset -- two props in one atlas can want opposite calls on the same
-- shade (see the POKECENTER entry).
--
-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } }
--
-- Only the four GB shade names exist; anything else is dropped, so a typo
-- degrades to the ordinary vote rather than emptying the background.
local SHADES = { black = true, dark = true, light = true, white = true }
function TileShape.propBg(tilesetId)
local hit = bgCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.prop_bg
local out, any = {}, false
if type(list) == "table" then
for _, rule in ipairs(list) do
if type(rule) == "table" and type(rule.tiles) == "table"
and type(rule.shades) == "table" then
local set, n = {}, 0
for _, name in ipairs(rule.shades) do
if SHADES[name] then
set[name] = true
n = n + 1
end
end
if n > 0 then
for _, t in ipairs(rule.tiles) do
if type(t) == "number" then
out[t] = set
any = true
end
end
end
end
end
end
bgCache[tilesetId] = any and out or false
return bgCache[tilesetId] or nil
end
-- What a bookcase rank does with the rows it VACATES -- the ones behind the
-- one-cell-deep box it collapses onto (a tileset entry's
-- bookcase_backfill). Returns the mode name, or nil for the default.
--
-- "above" hand them the cell immediately above the run: its shape and
-- its art. A wall set INTO a terrace wants this -- the ground
-- behind it is more terrace, not a trench.
-- nil skip them and paint the map's commonest ground underneath,
-- which is right for a free-standing shelf against a wall.
--
-- Per tileset because it is a statement about what the drawing depicts, and
-- the answer differs: the Mart's racks and Red's shelves stand in a room,
-- the Plateau's gate walls are cut into a hillside.
function TileShape.bookcaseBackfill(tilesetId)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local mode = entry and entry.bookcase_backfill
return mode == "above" and mode or nil
end
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
function TileShape.invalidate()
spec = nil
cache = {}
figCache = {}
bgCache = {}
end
return TileShape
+526 -12
View File
@@ -30,6 +30,9 @@ local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local GlassMask = V.require("GlassMask")
local Voxel3D = {}
@@ -200,6 +203,13 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
uniform float glassPhase; // the glint's phase: advances with TRAVEL
uniform float glassGlint; // and its strength: 0 while standing still
uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass)
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
@@ -207,12 +217,44 @@ local SHADER = [[
// blending keeps those texels out of the depth buffer, so a model never
// carves a transparent hole out of whatever stands behind it
if (p.a < 0.5) discard;
vec3 rgb = p.rgb * vShade * sunlight(vSun);
// the hour's tint multiplies like the sun terms do: it is LIGHT, the
// same warm or moonlit cast on every surface, not a palette swap
vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
#ifdef VOXEL_GRID
// darken what is there rather than painting a colour, so a seam across
// dark grass and one across a white roof each stay in their own palette
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
#endif
// WINDOW GLASS, marked per atlas texel by the mask (see GlassMask).
// By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES
// -- the phase is fed by the camera's own travel and the strength dies
// within a beat of standing still, because a reflection is something
// the viewpoint does: still camera, still glass. It lifts the texel
// toward sky-white and leaves the art visible through it. After dark
// the pane is LIT: the texel's own shine pattern carried into a warm
// lamp colour, replacing the shaded answer above -- so a lit window
// ignores the sun, every shadow and the hour's tint, exactly as a
// window with a lamp behind it does.
// glassOn gates the whole thing per DRAW: the mask is shaped like the
// tileset atlas, and only meshes textured FROM that atlas may consult
// it -- a character samples its own sprite sheet, whose coordinates
// land on the mask's pane rectangles by accident and would stripe the
// cast with lamplight at night.
float glass = Texel(glassMask, tc).a * glassOn;
if (glass > 0.0) {
// the sweep lives in the PANE's own space (atlas texels), not the
// screen's: a pattern anchored to the screen has the world sliding
// through it at zoom speed whenever the camera pans, which strobed --
// worst where the pan and the phase ran opposite ways. Anchored to
// the glass, panning moves nothing; only the phase does, a fraction
// of a texel per step, the same in every walking direction.
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
@@ -232,9 +274,75 @@ 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 held = nil -- and the whole record for it
local active = false
-- A READABLE depth canvas, so a later pass in the same frame can ask the
-- buffer questions rather than only write to it -- which is the whole of
-- what makes screen-space reflections possible (see Water).
--
-- `depth = true` in the target list, which is what this used to bind,
-- allocates an internal depth buffer that is written and tested and can
-- never be sampled. An explicit canvas is the same buffer with a texture
-- handle on it, and costs the same memory.
--
-- nil where the driver will not make one -- every depth format is optional
-- in GLES and a canvas is the only honest test of any of them, so this asks
-- for several in order of preference: 24 bits, the same 24 riding a stencil
-- (a pairing some mobile drivers will texture when the bare format they
-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read.
-- Refused all four, beginScene falls straight back to the internal buffer,
-- which is exactly the old behaviour minus the reflections.
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
local function newDepth(w, h)
if not (love.graphics and love.graphics.newCanvas) then return nil end
local c = nil
for _, format in ipairs(DEPTH_FORMATS) do
local ok, made = pcall(love.graphics.newCanvas, w, h,
{ format = format, readable = true })
if ok and made then c = made break end
end
if not c then return nil end
-- nearest: a depth is a distance, and a blend of two of them is a
-- distance to nothing. The march wants the texel it landed on.
pcall(c.setFilter, c, "nearest", "nearest")
pcall(c.setWrap, c, "clamp", "clamp")
-- and no compare mode: with one set, Texel returns a 0/1 shadow verdict
-- instead of the depth, which is not what any reader here wants
pcall(c.setDepthSampleMode, c)
return c
end
-- The bound target for the slot this pass holds: the colour canvas plus
-- either the readable depth canvas or the internal buffer.
local function depthTarget()
if held and held.depth then
return { held.canvas, depthstencil = held.depth }
end
return { canvas, depth = true }
end
-- Every GPU object one slot owns. The mirror is the copy of the frame the
-- water pass reads (see beginWater); it is only ever made if something asks
-- for one, so a session that never sees a lake never pays for it.
local function releaseSlot(slotHeld)
for _, key in ipairs({ "canvas", "depth", "mirror" }) do
local obj = slotHeld[key]
if obj and obj.release then pcall(obj.release, obj) end
slotHeld[key] = nil
end
end
local IDENTITY = Mat4.identity()
-- Whether the driver admits to supporting derivatives. Only a hint --
@@ -312,20 +420,106 @@ 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
-- ------- which way, and how steeply, this camera looks
--
-- Two facts about the view direction, set alongside the eye and the focus
-- because they ARE the eye and the focus, and read by anything that has to
-- reason about the camera's ATTITUDE rather than about a point in front of
-- it:
--
-- lookFlat the view direction flattened onto the ground plane and
-- normalized -- "the way the horizon lies from here", which is
-- what a reflection leans toward at the steeper rungs (Water).
-- descent how far below horizontal the view runs, as a sine: 0 looking
-- level, 1 looking straight down. It is the number that says
-- whether there is a horizon in frame at all, and it answers
-- the same way for the orbit and for a placed battle camera --
-- which is why this is derived from the two vectors rather than
-- read off Voxel.angle, a rung the battle camera does not have.
--
-- A camera looking exactly straight down has no horizontal direction at all,
-- and lookFlat then keeps whatever it last held rather than becoming a zero
-- vector nothing downstream could normalize.
Voxel3D.lookFlat = { 0, 0, -1 }
Voxel3D.descent = 0
local function setLook(eye, focus)
local dx = focus[1] - eye[1]
local dy = focus[2] - eye[2]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len < 1e-6 then return end
Voxel3D.descent = math.max(0, math.min(1, -dy / len))
local flat = math.sqrt(dx * dx + dz * dz)
if flat < 1e-6 then return end
Voxel3D.lookFlat = { dx / flat, 0, dz / flat }
end
-- 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
-- kept beside the eye for horizonY: where the sky's pale end goes is a
-- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus
setLook(eye, focus)
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))
-- kept for the passes that measure an ANGLE against this camera rather
-- than a position: the water's reflected sun is sized in radians, and
-- radians per canvas pixel is exactly this over the frame height
Voxel3D.fovY = cam.fov
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
-- the FOV that makes a straight-down camera at `dist` frame exactly `vh`
-- world pixels, which is the framing the flat view already has
local fov = 2 * math.atan(1 / (2 * focal))
Voxel3D.fovY = fov
local focus = { cx, 0, cy }
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
-- The height the orbit looks AT -- the smoothed ground under the
-- player's feet (VoxelScene tracks it), so climbing a terrace does not
-- slide the walker up the screen at a pitched camera. Zero on flat
-- terrain, which is the framing this rig always had.
local fy = Voxel3D.focusY or 0
local focus = { cx, fy, cy }
local eye = { cx, fy + dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye
Voxel3D.focus = focus
setLook(eye, focus)
-- perpendicular to the view direction in the YZ plane: north is screen-up
-- when looking straight down, +Y is screen-up when looking level. Never
-- parallel to the view direction, so there is no degenerate a = 0 case.
@@ -343,6 +537,92 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
end
-- ------- the horizon
--
-- Where the ground plane's vanishing line lands, in canvas pixels down from the
-- top edge, or nil when this camera has no horizon to find.
--
-- Not a fraction picked by eye. A direction ALONG the ground is a point at
-- infinity, and putting one through the same matrix the geometry is drawn with
-- gives the line every ground plane in the scene converges on -- so the sky's
-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides
-- the camera tween instead of having to be retuned against it.
--
-- The world CURVE is not in it, and cannot be: it bends distant ground down in
-- the vertex shader, so the ground's apparent edge sits BELOW this line by
-- however much the bend took. What shows in between is the haze the sky's fill
-- already is, which is what a curved-away horizon should look like.
--
-- nil in two cases, both meaning "no horizon in this frame": a camera looking
-- straight down, whose forward direction has no horizontal part to send to
-- infinity, and one whose vanishing line is behind it.
function Voxel3D.horizonY(h)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
-- a DIRECTION, so its w is zero and the matrix's translation column drops
-- out; the clip-space Y flip is already baked into m, so this comes out in
-- canvas coordinates rather than needing one
local y = m[5] * dx + m[7] * dz
local w = m[13] * dx + m[15] * dz
if w <= 1e-6 then return nil end
return (y / w * 0.5 + 0.5) * h
end
-- ------- the hour's light
--
-- What the scene shader multiplies every surface by (see dayTint in the
-- shader). Set per pass by whoever knows what map is being drawn --
-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
-- outdoors" is the map's question, not this pass's. Neutral until somebody
-- answers it, so a caller that never does draws exactly what it always drew.
Voxel3D.tint = { 1, 1, 1 }
-- The window-glass pass, set the same way and for the same reason: the
-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
-- panes are belongs to the hour and to being outdoors at all
-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect.
Voxel3D.glassMask = nil
Voxel3D.glassNight = 0
-- the glint, fed by the camera's TRAVEL rather than by a clock (see
-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and
-- the strength is 0 whenever the view has been still for a beat
Voxel3D.glassPhase = 0
Voxel3D.glassGlint = 0
-- The sun or moon disc's place on this camera's canvas, or nil when the
-- body is set, on the southern half of the sky, or behind the camera.
--
-- The direction comes from DayNight (true bearing, squashed elevation) and
-- goes through the SAME matrix the geometry is drawn with, as a point at
-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
-- azimuth is honest: it stands over the point on the horizon its shadows
-- point away from, at every pitch, fov, window shape and zoom.
--
-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
function Voxel3D.skyBody(w, h)
local m = Voxel3D.vp
local b = m and DayNight.body()
if not b then return nil end
local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
if ww <= 1e-6 then return nil end
local amt, color = DayNight.glow()
return {
x = (x / ww * 0.5 + 0.5) * w,
y = (y / ww * 0.5 + 0.5) * h,
moon = b.moon,
glowAmt = amt,
glowColor = color,
}
end
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -351,7 +631,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,22 +642,59 @@ 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 slotHeld = slots[name]
if not (slotHeld and slotHeld.w == w and slotHeld.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 slotHeld then releaseSlot(slotHeld) end
-- the depth canvas is sized with its colour, so a window resize
-- reallocates the pair together and they can never disagree
slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) }
slots[name] = slotHeld
end
held = slotHeld
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,
{ canvas, depth = true })
local ok = pcall(love.graphics.setCanvas, depthTarget())
if not ok and held.depth then
-- the readable canvas would not bind; fall back to the internal buffer
-- for the rest of this session rather than losing the whole 3D pass
pcall(held.depth.release, held.depth)
held.depth = nil
ok = pcall(love.graphics.setCanvas, depthTarget())
end
if not ok then
pcall(love.graphics.setCanvas)
return false
end
-- Ahead of the clear, because the sky's bands are placed off the ground
-- plane's vanishing line and that is a property of this matrix.
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
-- This frame's pixels per WORLD pixel: the size a diorama pixel is on
-- screen. The sky's dither grid is cut to it, and so is the water's --
-- one number, so the two break up on the same checkerboard.
Voxel3D.cell = w / math.max(1, vw or w)
-- and where the sky's bottom edge lands, which is what the reflection
-- reads its bands against (see Water). nil when nothing painted bands.
Voxel3D.skyEdge = (sky and sky.bands)
and Sky.region(h, Voxel3D.horizonY(h)) or nil
if sky then
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
-- The sky goes down here, in the one window in this function where a
-- rectangle is just a rectangle: the depth mode and the scene shader are
-- both set below. Sky.paint puts them aside anyway -- beginScene is not the
-- only thing that has ever left a shader bound.
--
-- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
-- pixel is on screen: the sky's dither grid is cut to that, so its squares
-- are the same size as the world's own and follow every resize and zoom.
-- The banded sky also hangs the hour's sun or moon (skyBody projects it
-- through this very camera); a flat sky has no bands and hangs nothing.
Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell,
sky.bands and Voxel3D.skyBody(w, h) or nil)
else
love.graphics.clear(0, 0, 0, 0, true, true)
end
@@ -386,7 +705,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
love.graphics.setMeshCullMode("none")
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
pcall(sh.send, sh, "eye", Voxel3D.eye)
-- the sun's frame, filled by ShadowMap just before this pass opened.
@@ -410,9 +728,27 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
-- start out flattening everything it drew.
pcall(sh.send, sh, "ghost", 0)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
-- the hour's light, as the caller last set it (see Voxel3D.tint)
pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
-- the window glass: the tileset's mask (or the blank -- the sampler is
-- declared either way, and unbound is a driver-dependent crash), how lit
-- the panes are, and the movement-fed glint as the caller last set it
local mask = Voxel3D.glassMask or GlassMask.blank()
if mask then
pcall(sh.send, sh, "glassMask", mask)
local ok, mw, mh = pcall(mask.getDimensions, mask)
pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 })
end
pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0)
pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0)
pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0)
-- on until a sprite pass says otherwise, reset per frame like `ghost`
pcall(sh.send, sh, "glassOn", 1)
-- the curved world bends about the camera's focus, so the horizon keeps
-- a fixed distance ahead of the player rather than sitting on the map
Voxel3D.curveK = WorldCurve.k(vh)
-- a fixed distance ahead of the player rather than sitting on the map.
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
local placed = Voxel3D.camera
Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh)
Voxel3D.curveX, Voxel3D.curveZ = cx, cy
pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK })
-- clip w at the focus point, the reference depth project() reports scale
@@ -483,6 +819,167 @@ function Voxel3D.beginGhost()
end
end
-- Flatten whatever is drawn next to one solid colour, or nil to stop.
--
-- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted
-- depth test and half alpha -- this is for something drawn normally that
-- simply wants to come out one colour, which is what a hit flash on a sprite
-- is. beginScene resets the uniform every frame, so a pass that forgets to
-- clear it cannot leak into the next one.
-- `amount` is how far toward that colour, 0..1; omitted is all the way.
-- Anything short of 1 leaves the sprite's own shading showing through, which
-- is the difference between a hit flash and a white cut-out.
function Voxel3D.flatten(color, amount)
if not (active and activeShader) then return end
local sh = activeShader
if color then
pcall(sh.send, sh, "ghostColor", color)
pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1)))
else
pcall(sh.send, sh, "ghost", 0)
end
end
-- ------------------------------------------------------- the water pass --
--
-- A reflective surface has to READ the frame it is being drawn into: the
-- colour of what is standing around it and the depth that says where. Both
-- are attachments of the target this pass is bound to, and a texture cannot
-- be sampled while it is one -- so for the length of the water draw the
-- frame is taken apart:
--
-- the COLOUR is copied to a mirror canvas, which is a texture like any
-- other and is what the reflection samples.
--
-- the DEPTH is simply detached. The water shader does the test itself
-- against the texture (see Water), which is the same comparison the
-- hardware would have made -- what it gives up is depth WRITES, and water
-- is flat, never overlaps itself, and has nothing drawn under it later.
--
-- `paint`, when given, is called with the MIRROR bound and the scene shader
-- set, to add things that must be REFLECTED without being composited yet.
--
-- The characters are the whole reason it exists. Gen 1 draws people over
-- the world and water is world, so the cast has to composite AFTER the
-- water -- but a reflection can only contain what was drawn BEFORE it, and
-- a lake with everyone standing beside it and nobody in it reads as glass.
-- Painting them into the mirror alone settles both: they are in the picture
-- the water reflects and not yet in the picture the water is drawn into.
--
-- They go down depth-TESTED and depth-WRITE-FREE. Tested, so a figure behind
-- a building is behind it in the reflection too; write-free because the very
-- next thing to read that buffer is the water's own depth test, and a cast
-- that had written to it would punch itself out of the water it is standing
-- beside.
--
-- Returns the two textures, or nil when there is nothing to hand over: no
-- readable depth canvas on this driver, or no pass open. A caller that gets
-- nil draws its water like ordinary terrain, which is what this mode always
-- did.
--
-- MUST be paired with endWater, which puts the frame back together.
function Voxel3D.beginWater(paint)
if not (active and canvas and held and held.depth) then return nil end
if not held.mirror then
local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
if not (ok and c) then return nil end
pcall(c.setFilter, c, "nearest", "nearest")
pcall(c.setWrap, c, "clamp", "clamp")
held.mirror = c
end
love.graphics.setShader()
-- the frame's own depth rides along, so the paint below can test against
-- it; the copy underneath switches the test off rather than detaching it
local ok = pcall(love.graphics.setCanvas,
{ held.mirror, depthstencil = held.depth })
if not ok then
pcall(love.graphics.setCanvas, depthTarget())
return nil
end
love.graphics.setDepthMode("always", false)
-- COLOUR only. The last two arguments are what keep the depth buffer the
-- frame's rather than this canvas's: cleared here, the water's own depth
-- test a few lines later would find nothing in front of anything and every
-- lake would draw straight through the buildings standing in it.
love.graphics.clear(0, 0, 0, 0, false, false)
-- premultiplied over a cleared target is a straight copy: every channel
-- lands exactly as it stood, including the alpha, so the mirror is the
-- frame rather than the frame composited against something
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas)
love.graphics.setBlendMode("alpha")
if paint and activeShader then
love.graphics.setDepthMode("lequal", false)
love.graphics.setShader(activeShader)
pcall(paint)
love.graphics.setShader()
end
love.graphics.setDepthMode()
-- and back to the scene canvas WITHOUT its depth: that texture is about
-- to be read
if not pcall(love.graphics.setCanvas, canvas) then
pcall(love.graphics.setCanvas, depthTarget())
return nil
end
return held.mirror, held.depth
end
-- Put the frame back: depth reattached, depth test and the scene shader as
-- the pass had them. Safe to call after a beginWater that returned nil.
function Voxel3D.endWater()
if not active then return end
pcall(love.graphics.setCanvas, depthTarget())
pcall(love.graphics.setDepthMode, "lequal", true)
love.graphics.setColor(1, 1, 1, 1)
if activeShader then love.graphics.setShader(activeShader) end
end
-- Whether a reflective water pass can run in this frame at all -- there is
-- a depth texture to read. Callers use it to choose between the water
-- shader and an ordinary terrain draw before they start moving canvases.
function Voxel3D.depthReadable()
return (active and held and held.depth) and true or false
end
-- Whether what is drawn next carries the voxel wireframe. false for the
-- length of a draw, true to put it back.
--
-- The wireframe reads a mesh's OWN model space and darkens its integer
-- planes (see VoxelGrid), which is only a wireframe because every mesh in
-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a
-- character card in the sprite's own pixels. A mesh whose model space does
-- not mean that gets no wireframe out of the same shader -- it gets
-- whichever of its integer planes happen to fall inside it, which is a
-- stray line rather than a seam.
--
-- So this is not a style switch. It is how a mesh that is not on the voxel
-- grid says so, and the alternative -- rescaling such a mesh until its
-- units happen to be voxels -- would change what it IS to satisfy a
-- shading pass.
--
-- Sent rather than branched because the plain scene shader has no such
-- uniform, and the send simply does not take there -- which is right: with
-- no wireframe compiled in there is nothing to suppress.
function Voxel3D.seams(on)
if not (active and activeShader) then return end
pcall(activeShader.send, activeShader, "gridDark",
on and VoxelGrid.DARK or 0)
end
-- Whether what is drawn next may consult the glass mask. false for the
-- length of a sprite-sheet pass, true to put it back.
--
-- Same shape as seams(), for the same reason: the mask means "this ATLAS
-- texel is window glass", so it is only an answer for meshes textured from
-- the tileset atlas. A sprite sheet's coordinates land wherever they land
-- on it, and at night that painted lamplight stripes down whoever was
-- standing in the wrong part of their own sheet.
function Voxel3D.glass(on)
if not (active and activeShader) then return end
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
@@ -656,10 +1153,27 @@ function Voxel3D.canvas()
return canvas
end
-- The bound canvas's pixel size, for a pass that has to work in screen
-- coordinates (the water's reflection marches in them).
function Voxel3D.size()
return canvasW, canvasH
end
-- Drop the GPU objects (window resize, hot reload).
function Voxel3D.invalidate()
for name, slotHeld in pairs(slots) do
releaseSlot(slotHeld)
slots[name] = nil
end
canvas, canvasW, canvasH = nil, 0, 0
held = nil
ShadowMap.invalidate()
-- the sky is part of this pass and holds a shader of its own
Sky.invalidate()
-- and so does the water, for the same reason
V.require("Water").invalidate()
-- and the glass masks are textures of this context too
GlassMask.invalidate()
end
return Voxel3D
+12
View File
@@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
{ false, true }, { "OFF", "ON" })
-- A pass that needs the wireframe whatever the player left the row on sets
-- this for the length of its own draw and puts it back after. nil means
-- "follow the setting", which is every frame outside such a pass.
--
-- The overworld battle is the one user: a fight is a STAGED shot, not the
-- world being walked around in, and the seams are what make it read as
-- constructed rather than as a photograph of somewhere. The row still owns
-- what free-roam looks like, and is not written to -- switching the mode off
-- mid-battle would silently rewrite the player's own setting.
VoxelGrid.override = nil
function VoxelGrid.enabled()
if VoxelGrid.override ~= nil then return VoxelGrid.override end
return VoxelGrid.setting:get() and true or false
end
+427 -56
View File
@@ -1,4 +1,4 @@
-- Voxel world mode: assemble and draw one frame of the 3D scene.
-- Voxel world mode: assemble and draw one frame of the 3D scene.
--
-- World space is world pixels and shares its origin with the 2D paths, so
-- the terrain mesh needs no transform at all and a connected map just
@@ -20,11 +20,19 @@ local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local Elevation = V.require("Elevation")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local VoxelScene = {}
-- the camera's smoothed focus height (see render); nil until first framed
local focusHeight = nil
-- What the active display mode actually paints with.
--
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
@@ -49,12 +57,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- At the top rung the camera is pitched far enough over that the horizon
-- comes into frame and a good part of the picture is void -- so the void
-- becomes the sky, and the diorama reads as standing under something
-- rather than floating on a black plate. Below that rung the camera looks
-- down steeply enough that the horizon is off-screen, and painting the
-- void only tints the gaps between meshes, so it stays transparent.
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
@@ -67,30 +79,78 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
-- mismatch the terrain bake had.
--
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
-- same way by the same seam; they are separate because the flat one also has to
-- serve an indoor void and a battle's arena, which want a colour rather than a
-- sky.
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
{ 64, 120, 192 }, { 16, 40, 80 } }
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
-- fade across the approach to the top rung, so the sky arrives with the
-- camera tween instead of popping in on the keypress
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below
local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung
if to <= from then return deg >= to and 1 or 0 end
local t = (deg - from) / (to - from)
if t < 0 then return 0 end
if t > 1 then return 1 end
return t
-- the ramp as the display mode has it, which is the only form anything here
-- should be reading it in
local function skyRamp()
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
end
local function skyFor(map)
-- Full strength at every rung: the sky is painted wherever the diorama is.
--
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
-- camera up from flat, and the sky comes up with it over the first few degrees
-- rather than appearing whole on the keypress -- which is also what keeps a
-- top-down camera, where there is no void worth speaking of, from painting one.
local SKY_FADE_DEG = 8
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
if deg <= 0 then return 0 end
local t = deg / SKY_FADE_DEG
return t < 1 and t or 1
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 = skyRamp()
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.
--
-- One flat colour, which is what a caller that only needs something to clear the
-- void to wants -- the overworld battle's arena shot is one of those. The
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
function VoxelScene.skyColor(map, t)
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
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 }
if not t or t <= 0 then return nil end
local sky = VoxelScene.skyShade(SKY_SHADE, t)
-- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
-- gold at dusk, navy at night -- so a battle staged on the map at
-- midnight is under a midnight void, not a noon one. Free-roam is
-- unchanged by this: Sky.dress overwrites the fill with the same value.
local haze = Sky.haze()
if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
return sky
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
-- (lib/Sky.lua).
--
-- Only here, and deliberately. This is the sky the walking camera stands under,
-- where the horizon is a quarter of the way down the frame at the top rung and
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
-- placed camera whose horizon sits above the frame entirely, so it keeps the
-- flat fill it has always had -- there is no gradient to see from down there,
-- and the arena's look is not this rung's to change.
local function skyFor(map)
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
if not sky then return nil end
return Sky.dress(sky)
end
VoxelScene._skyFor = skyFor -- named for the suite
@@ -110,27 +170,39 @@ local YAW = {
-- top of it rather than sunk into it. Uses the same bottom-left collision
-- tile the engine walks on (Map:cellTile).
local function groundAt(map, cellX, cellY)
-- The terrain's base under the cell -- 0 wherever no elevation field
-- exists (every interior), so the flat world keeps its old answers.
local b = Elevation.baseAt(map, cellX, cellY)
-- Off the map, cellTile border-extends into the map's borderBlock --
-- which on maps ringed with trees is a RAISED tile. The only entity
-- ever standing off-map is the player mid seam-step (placed one cell
-- before the connection entry), and the ground actually rendered
-- there is the departed neighbour's flat walkway: height 0. Without
-- this, crossing into such a map hoisted the walker tree-high for
-- exactly one step -- the "hops like a ledge" seam bug.
if not map:inBounds(cellX, cellY) then return 0 end
-- there is the departed neighbour's flat walkway: the border apron's
-- own base (baseAt clamps to the nearest body cell, which is how the
-- apron is meshed). Without this, crossing into such a map hoisted
-- the walker tree-high for exactly one step -- the "hops like a
-- ledge" seam bug.
if not map:inBounds(cellX, cellY) then return b end
local shapes = TileShape.forMap(map)
local s = shapes[map:cellTile(cellX, cellY)]
if not s then return 0 end
if not s then return b end
-- a recessed class (water) still supports whatever stands on it; only
-- raised ground lifts the model. Stairs never do: the class height is
-- the flight's TALL end, but the player enters at floor level and the
-- warp fires as they step in -- lifting them onto the geometry read as
-- climbing an invisible block
if s.art == "stair" then return 0 end
return s.h > 0 and s.h or 0
if s.art == "stair" then return b end
-- on solved terrain a ledge is the high plateau's rim, its lip flush
-- with the ground it belongs to: the solver's base IS its top. On a
-- flat map it is still the classic 6px bump you stand on top of.
if s.class == "ledge" and Elevation.fieldFor(map.id) then return b end
return b + (s.h > 0 and s.h or 0)
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
@@ -190,6 +262,36 @@ local function billboardPull()
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
end
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
-- local space with its feet on y = 0, and this stands it at its drawn
-- position and tips it back by exactly the camera's pitch -- the same
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
-- around him. No cell centring: unlike a character he is not standing on a
-- cell, he is standing where he was drawn, which may straddle two.
local function figureMatrix(f, offX, offZ)
local Voxel = V.require("VoxelState")
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.rotateX(Voxel.angle - math.pi / 2))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
-- Voxel3D.casterMatrix does it for a character.
local function figureCaster(f, offX, offZ)
return Mat4.mul(
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.scale(1, 1, 0))
end
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
local function eachFigure(map, offX, offZ, draw)
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
end
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
@@ -220,12 +322,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED is what the sun saw (castShadows draws
-- exactly this mesh), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
-- vertex asks whether the light reached it; see ShadowMap.snug for why
-- the lookup must match the stored transform to the letter
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
return true
end
@@ -316,17 +419,25 @@ function VoxelScene.prefetch(state)
-- crossing demotes the map just left, and it must not vanish from
-- behind the player while its body variant builds; its ring is
-- already masked out under this map's body, so the stand-in is safe.
local terrain = ChunkMesher.request(state.map, false, masks, true)
-- The water surface rides along with whichever variant answers: it was
-- cut out of that build's own geometry (ChunkMesher.pair), so the two
-- always come from the same slot and a lake is never drawn twice or left
-- as a hole.
ChunkMesher.request(state.map, false, masks, true)
local terrain, water = ChunkMesher.pair(state.map, false)
if not terrain then
terrain = ChunkMesher.peek(state.map, true)
terrain, water = ChunkMesher.pair(state.map, true)
end
local nbMesh = {}
local nbMesh, nbWater = {}, {}
for i, nb in ipairs(state.neighbors or {}) do
nbMesh[i] = ChunkMesher.request(nb.map, true)
or ChunkMesher.peek(nb.map, false)
ChunkMesher.request(nb.map, true)
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, true)
if not nbMesh[i] then
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false)
end
end
Voxel.ready = terrain ~= nil
return terrain, nbMesh
return terrain, nbMesh, water, nbWater
end
-- Capture every entity's pose for this frame. pose() advances the hop /
@@ -370,6 +481,156 @@ local function posesOf(state, spriteColors)
return posed, me
end
-- ------- the glint's drive
--
-- A reflection is something the VIEWPOINT does, so the window glint is fed
-- by the camera's own travel rather than by a clock: its phase advances
-- with distance covered and its strength fades in over a few steps of
-- walking and back out within a beat of standing still. Stand still and
-- the glass is still; move and the light crosses it.
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
-- texels (see the scene shader), so this is a FRACTION of a texel per world
-- pixel walked -- one full pass of the glint across a pane per eight or so
-- cells of travel, with no frame ever jumping it far enough to strobe.
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
function VoxelScene.glintStep(g, cx, cy)
local dist = 0
if g.x then
dist = math.abs(cx - g.x) + math.abs(cy - g.y)
end
g.x, g.y = cx, cy
g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi)
if dist > 0.05 then
g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN)
else
g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT)
end
return g
end
local glint = {}
-- ------- the cast
--
-- Everybody standing on the map: the walkers, and the authored FIGURES the
-- tileset draws into its own furniture (they ARE characters as far as the
-- artwork is concerned, just ones drawn by the tileset instead of by a
-- sprite sheet, so they get the same lean and the same camera-ward pull).
--
-- One function because it is drawn TWICE and the two must be identical: once
-- into the frame, and once into the water's reflection copy (see drawWater --
-- Gen 1 draws people over the world, and water is world, so the cast cannot
-- be composited before the water it has to appear in).
--
-- Characters carry no wireframe out here, whatever the V-GRID row says. The
-- seams are what makes the WORLD read as built out of voxels, and the people
-- walking around in it are the one thing that should read as drawn instead --
-- a grid over a 16x16 sprite lands a line every couple of display pixels and
-- turns a face into a mesh. (The battle pass makes the opposite call for its
-- own combatants, deliberately -- see BattleBillboard.)
--
-- Sprite sheets until the figure pass: their texture coordinates mean
-- nothing to the tileset-shaped glass mask, so the glass is off or the
-- panes' atlas positions stripe the cast with lamplight at night.
local function drawCast(state, posed, atlasFor)
Voxel3D.glass(false)
Voxel3D.seams(false)
-- Characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
for _, p in ipairs(posed) do
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
-- back on for everything textured from the atlas again -- figures, grass
-- and flowers all sample it, where the mask's coordinates are honest
Voxel3D.glass(true)
-- Figures after the walkers, so a player standing in front of the couch
-- wins the overlap -- the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull,
ShadowMap.snug(caster))
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
ShadowMap.snug(caster))
end)
end
-- and the seams are back on for the terrain art that follows: grass and
-- flowers are the world's own drawing, not people
Voxel3D.seams(true)
end
-- ------- the water pass
--
-- Between the terrain and everything that stands on it, because water is a
-- MIRROR and a mirror can only reflect what is already down: the ground, the
-- shoreline, the trees and buildings behind it, and the sky the frame opened
-- with.
--
-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1
-- draws people over the world and water is world, so a surfing player has to
-- composite OVER the water they are sitting on -- which puts them after it,
-- and a reflection can only hold what came before it. So `cast` is painted
-- into the reflection copy alone (Voxel3D.beginWater), where it is in the
-- picture the water reflects and not yet in the picture the water is drawn
-- into. Both draws go through drawCast, so they cannot come out different.
--
-- The ray march finds them the honest way round: a sprite is not in the
-- DEPTH buffer at that point, so a ray aimed at one passes through to the
-- terrain standing behind it and reads the copy there -- where the sprite is
-- already painted. The reflection lands a hair off the sprite's own depth
-- and exactly on its colour, which at a lake's worth of ripple is the same
-- picture.
--
-- `draws` is a list of { mesh, texture, model }. Nothing is a special case:
-- with the row OFF, no depth texture to read, or a shader that would not
-- build, the same meshes go through the ordinary scene shader and come out
-- as the flat animated water this mode always drew.
-- The overworld's alone: the staged battle draws its water plain, always --
-- its placed camera reads this pass wrong, and a stage set wants painted
-- water anyway (see BattleScene, where the choice is argued).
function VoxelScene.drawWater(draws, cast)
local plain = true
if Water.enabled() and Voxel3D.depthReadable() then
local mirror, depth = Voxel3D.beginWater(cast)
local w, h = Voxel3D.size()
local ok = mirror and depth and Water.begin({
reflect = mirror, depth = depth,
vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0,
Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 },
screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY,
skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(),
lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent,
})
if ok then
for _, d in ipairs(draws) do
Water.draw(d[1], d[2], d[3])
end
Water.finish()
plain = false
end
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
-- the shader and the depth mode BEFORE it can discover it cannot go on,
-- so a frame that bails halfway through has to be put back together
-- exactly like one that succeeded -- otherwise the plain draw below (and
-- every pass after it) runs with no shader and no depth test.
Voxel3D.endWater()
end
if plain then
for _, d in ipairs(draws) do
Voxel3D.draw(d[1], d[2], d[3])
end
end
end
-- A stamp of everything the sun pass depends on. Nothing in it moving
-- means the shadow map it produced last frame is still exactly right, and
-- redrawing the whole world from the sun would buy nothing -- which is
@@ -391,6 +652,12 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- standing perfectly still
put(vw); put(vh)
put(math.floor((V.require("VoxelState").angle or 0) * 512))
-- the sun itself: the cycle swings the shear as the clock runs, and a map
-- lit from somewhere new must be redrawn from there too. Quantised by the
-- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
-- few times a minute rather than every frame.
put(math.floor(ShadowMap.KX * 128))
put(math.floor(ShadowMap.KZ * 128))
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
@@ -414,7 +681,7 @@ end
-- left out on purpose: thousands of tufts would cast a speckle no bigger
-- than the pixels it lands on, at the cost of the mesh being drawn twice.
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
atlasFor)
atlasFor, water, nbWater)
if not ShadowMap.available() then return end
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
if not ShadowMap.stale(sig) then return end
@@ -425,13 +692,41 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- The water surface, which the terrain mesh no longer carries (it is its
-- own reflective pass now -- see Water). The sun still has to see it, or
-- the map the light records has a hole at every lake and the frustum's
-- far plane answers for the surface a shoreline tree's shadow falls on.
ShadowMap.draw(water, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- flower billboards live outside the terrain mesh (they draw after the
-- characters, pulled -- see render), but the sun still sees them: a
-- handful of cutouts per meadow, unlike the grass left out below
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
-- handful of cutouts per meadow, unlike the grass left out below.
-- Every thin card from here down is SNUGGED toward the sun along its own
-- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead
-- of starting a bias-width away.
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
ShadowMap.snug(nil))
for _, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
-- From here down it is the CAST, marked as such in the map (see
-- ShadowMap.sprites) so water can decline them: everything the world casts
-- still shades a lake, a silhouette of somebody standing beside it does
-- not. Ground, roofs and the characters themselves take them as before.
ShadowMap.sprites(true)
-- authored figures cast too, for the same reason the flowers do: a
-- handful of cards per map, and a person with no shadow reads as pasted on
eachFigure(state.map, 0, 0, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster))
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
end)
end
for _, p in ipairs(posed) do
local def = p.sprite.def
@@ -439,10 +734,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
local mesh = SpriteBillboards.shadowQuad(def, frame)
if mesh then
ShadowMap.draw(mesh, p.sprite:resolveImage(),
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
mirror))
ShadowMap.snug(
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
mirror)))
end
end
ShadowMap.sprites(false)
ShadowMap.finish(sig)
end
@@ -452,12 +749,31 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- return nil: the engine keeps the 2D path for the frame and
-- Voxel.ready holds the camera tween at flat, so the switch waits
-- invisibly instead of freezing or tilting an empty stage.
local terrain, nbMesh = VoxelScene.prefetch(state)
local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state)
if not terrain then return nil end
local cam = state.camera
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
-- the hour's light, before anything is cast or drawn: point the shared
-- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
-- as dark as a cave at noon) and set the tint the scene shader multiplies
-- every surface by. A CANOPY map (Viridian Forest) is the case between:
-- the rig stays at noon and no sky is painted, but the hour's tint still
-- falls through the leaves -- night reaches a forest floor.
local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
DayNight.applyRig(outdoor)
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map))
-- and the window glass: the tileset's own panes (found in its art --
-- GlassMask), lit after dark. Outdoors only, like everything the clock
-- touches, which also keeps any pane-shaped art in an interior tileset
-- from picking up a glint.
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
local g = VoxelScene.glintStep(glint, cx, cy)
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
end
@@ -470,7 +786,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
end
local posed, me = posesOf(state, spriteColors)
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
water, nbWater)
-- The camera's focus height chases the ground under the player's feet,
-- eased so a 2px terrace tread is a glide rather than a pop; a WARP-
-- sized jump (raised route -> interior at 0) snaps instead of swooping
-- the whole frame through the floor.
local targetY = me and me.gh or 0
if focusHeight == nil or math.abs(targetY - focusHeight) > 24 then
focusHeight = targetY
else
focusHeight = focusHeight + (targetY - focusHeight) * 0.12
end
Voxel3D.focusY = focusHeight
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
@@ -498,6 +827,39 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.endShadows()
end
-- and the water over the top of it, reflecting everything just drawn plus
-- the sky the frame opened with (see drawWater).
--
-- After the fallback decals deliberately: those are the stand-in drop
-- shadows for a frame with no shadow map, they write no depth, and a
-- lake would otherwise wear one as a black smear. Water covers them,
-- which is the same answer the shadow map's own pass gives (see
-- ShadowMap.sprites) -- people do not shadow water either way.
local waterDraws = {}
if water then
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
end
for i, nb in ipairs(state.neighbors or {}) do
if nbWater and nbWater[i] then
waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy) }
end
end
-- the cast goes into the reflection copy only -- see drawWater for why it
-- cannot be composited yet and why it is drawn through the same function
-- the real pass below uses
if #waterDraws > 0 then
VoxelScene.drawWater(waterDraws, function()
drawCast(state, posed, atlasFor)
end)
end
-- Sprite sheets from here to the figure pass: their texture coordinates
-- mean nothing to the tileset-shaped glass mask, so the glass is off or
-- the panes' atlas positions stripe the cast with lamplight at night
Voxel3D.glass(false)
-- The player's silhouette goes down BEFORE the characters, so the only
-- thing it can meet in the depth buffer is the WORLD -- terrain, buildings,
-- trees. Drawn after the solid pass it would meet the player's own card
@@ -511,14 +873,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.endGhost()
end
-- characters, normally depth-tested: the camera-ward pull inside
-- Characters carry no wireframe out here, whatever the V-GRID row says.
-- The seams are what makes the WORLD read as built out of voxels, and
-- the people walking around in it are the one thing that should read as
-- drawn instead -- a grid over a 16x16 sprite lands a line every couple
-- of display pixels and turns a face into a mesh. (The battle pass makes
-- the opposite call for its own combatants, deliberately: that is a
-- staged shot rather than the world being walked around in -- see
-- BattleBillboard.)
--
-- Characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
for _, p in ipairs(posed) do
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
drawCast(state, posed, atlasFor)
-- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet
@@ -543,11 +911,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- ON, while the nearest flower of the cell south (+20) stays in front
-- and keeps overdrawing their feet.
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
-- flowers are snugged casters too, so they read their own shadowing
-- through the same snugged transform the sun stored them with
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull)
fpull, ShadowMap.snug(nil))
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
return Voxel3D.endScene()
+52 -2
View File
@@ -23,10 +23,60 @@
local Voxel = {}
Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" }
-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
-- its intended state at once -- this camera, the miniature blur at full, the
-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
-- it rather than assembling it from four rows. It sits directly after OFF
-- because that is the order those two get used in.
--
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
-- like, and two rungs may look the same while meaning different things.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
-- the rung FULL sits on, so nothing has to hunt for it by label
Voxel.FULL_LEVEL = 1
function Voxel.isFull(level)
return (level or Voxel.level) == Voxel.FULL_LEVEL
end
-- ------- what the hotkey walks
--
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
-- cycler: pressing it should change the camera and nothing else, and FULL
-- reaches in and rewrites four other settings. Landing on it by accident,
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
-- row, which is where a preset that changes other rows belongs.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
-- The rung a press moves to from `level`.
--
-- A level that is not on the key's path -- FULL, reached from the menu --
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
-- rung, so retuning FULL moves the key's answer with it.
function Voxel.nextHotkeyLevel(level)
level = level or Voxel.level
local order = Voxel.HOTKEY_ORDER
local at = nil
for i, rung in ipairs(order) do
if rung == level then at = i break end
end
if not at then
local deg = Voxel.ANGLES_DEG[level + 1]
for i, rung in ipairs(order) do
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
end
end
if not at then return order[1] end
return order[at % #order + 1]
end
Voxel.level = 0
Voxel.angle = 0
Voxel.from = 0
+1303
View File
File diff suppressed because it is too large Load Diff
+476 -15
View File
@@ -77,6 +77,17 @@ 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")
local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
local Water = V.require("Water")
-- 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 +155,26 @@ 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 day/night clock, on the same always-running tick: Pipelines.update
-- runs whatever the level, so time passes with the mode off, through
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
-- would mid-walk
DayNight.update(dt)
-- The overworld battle rides this hook rather than owning a pipeline of
-- its own, because it owns no pass of the FRAME: it draws under a battle
-- screen the engine composites, which is not a stage the registry has.
-- 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 +216,7 @@ mod.content.render_pipelines:register("voxel", {
invalidate = function()
Voxel3D.invalidate()
OverworldBattle.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
end,
})
@@ -222,10 +253,114 @@ 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 water reflecting everything it can: FULL is the diorama at its
-- most photographed, and a lake with the sky and the shoreline in it is
-- most of what makes the model read as being outdoors
Water.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 and then LET GO of -- unlike the rows above, both
-- battle rows stay on the menu under FULL (see the rows hook), so this is
-- where the preset puts them and not where they are held.
OverworldBattle.setting:setIndex(1, Game)
-- with both mons out there on it: BACK SPRITES keeps the player's own on the
-- menu, which is the one part of the old screen FULL is least about. Set the
-- same way, and changed back on the same row a keypress later.
OverworldBattle.backSetting:setIndex(1, Game)
-- and the battle screen the staged fight is composed for. WIDE re-lays that
-- screen out on a 304x144 surface, which moves every anchor the arena camera
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
-- fights on, so the layout follows them.
OverworldBattle.forceOG(Game)
-- and the sky on the clock on the wall: FULL pins DAYTIME to SYNC. Unlike
-- the rest of the preset this one IS held, not just set -- the row is off
-- the menu while FULL owns it (the rows hook below), so a value changed
-- under it could never be seen or changed back.
DayNight.forceSync(Game)
if Game.writeOptions then pcall(Game.writeOptions, Game) end
end
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
-- concerned: the 3D-BTL row, and nothing else.
--
-- It used to answer yes under FULL as well, on the grounds that FULL owned
-- that row and switched it on. FULL no longer owns it -- the row stays on the
-- menu under FULL and can be switched off there (see the rows hook) -- so that
-- clause would now claim staged battles for a preset the player had just
-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
-- never staged. The row is the only thing that decides, which is what every
-- other reader of this setting already believed: OverworldBattle.begin and
-- wantsFront both gate on enabled() alone.
--
-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
-- this mode's rows say ON on a machine without a depth buffer too, and a menu
-- that claims 3D battles are on must not also offer the layout they cannot be
-- drawn in.
local function stagedBattles()
return OverworldBattle.enabled()
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
{ Water.setting,
"Reflections on water. FULL adds screen-space reflections of the "
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
.. "the sun and the moon alone, which is most of the look for a "
.. "fraction of the cost." },
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
-- knobs; what a battle is drawn over, and how it is framed, are not that.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift.",
full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
-- and a row that no longer decides anything is worse than no row.
{ OverworldBattle.backSetting,
"Keep your own Pokemon on the battle menu, seen from behind in its "
.. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.",
when = function() return stagedBattles() end, full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
}
local schema = {}
@@ -236,17 +371,19 @@ 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)
-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key)
--
-- 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
@@ -254,9 +391,12 @@ mod.options:define(schema)
-- AND the engine's TILT on the same press.
--
-- Consequences worth being explicit about: while this mod is enabled, TILT
-- (3) and GBC FX (5) are unreachable by key. Both are still reachable on
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
-- registry already forces it off whenever a world pipeline takes the pass.
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
-- menu too, where both rows are taken away and both values held at zero (see
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
-- flat fake of what this mode does for real, the registry already forces it
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
-- present pass over the top of the diorama. Uninstalling puts both back.
--
-- Everything the engine does around a pipeline hotkey has to happen here
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
@@ -268,6 +408,8 @@ local HOTKEYS = {
["6"] = "pipeline", -- tiltshift, likewise
["5"] = VoxelGrid.setting,
["7"] = WorldCurve.setting,
["8"] = OverworldBattle.setting,
["9"] = Water.setting,
}
do
@@ -283,7 +425,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,11 +459,20 @@ 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 four 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. Three of them parameterise that
-- pass; the fourth (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)
-- 8 is one of the two ways staged battles get switched on, and they
-- pin BATTLE LAYOUT to OG (see the rows hook). The other keys
-- parameterise the pass and leave the layout alone; the guard answers
-- for all of them, so nothing here has to know which key it was.
if stagedBattles() then OverworldBattle.forceOG(self) end
return
end
end
@@ -316,15 +480,126 @@ 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 the settings that describe the LOOK, so while it is selected those
-- 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.
--
-- The battle rows are the exception and they stay; see the rows hook.
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
-- ------- TILT and GBC FX are gone while this mod is installed
--
-- Both fight the diorama, and both were already half-taken: the mode's own key
-- (3) forces them off on every press, and the registry switches TILT off
-- whenever a world pipeline takes the pass. What was left was two rows the
-- player could set and watch get reverted -- TILT is the flat fake of what
-- this mode does for real, and GBC FX is a full-screen present pass over the
-- top of the whole thing.
--
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
-- Hiding a live setting is a trap: a save written before the mod was installed
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
-- back off. Pinned wherever the value could have arrived from -- the menu
-- opening, a save being loaded or begun -- so there is no route by which one
-- of them is on and unreachable.
--
-- Everything they did is still reachable: uninstall the mod and both rows are
-- back, at whatever they were last set to.
local function pinEngineFx(game)
game = game or require("src.core.Game")
local opts = game and game.save and game.save.options
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local changed = false
if opts then
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
opts.tilt, opts.gbcfx = 0, 0
end
pcall(Tilt.setLevel, 0)
pcall(GBCFX.setLevel, 0)
if changed and game.writeOptions then pcall(game.writeOptions, game) end
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")
-- ahead of every branch below, including FULL's early return: these two are
-- off the menu whatever else this mod is or is not doing
pinEngineFx(game)
dropRow(out, "tilt")
dropRow(out, "gbcfx")
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
-- one away. While a fight can be staged on the map, OG is the only layout it
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
-- and the row comes off the list on the same reasoning as the rows FULL owns:
-- a row that no longer decides anything is worse than no row. Nothing is
-- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
-- keypress.
if stagedBattles() then
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
return out
local full = Voxel.isFull(Pipelines.level("voxel"))
if full then
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-- horizon bend, the blur, the hour -- so those come off the menu and
-- DAYTIME is held at SYNC while its row is unreachable.
DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do
-- Two things decide whether a row is offered.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
-- applyFull); it does not hold them, so leaving them on the menu is the
-- difference between a preset and a lock.
--
-- And a row whose own switch is off the table this frame (BACK SPRITES,
-- which needs a staged fight to be about) is left off with it. The mod
-- manager's page carries every one of them either way.
local offered = (entry.full or not full)
and (not entry.when or entry.when())
if offered then extra[#extra + 1] = entry[1]:row() end
end
return insertGrouped(out, extra)
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -334,6 +609,15 @@ mod.events:on("mod.options_changed", function(payload)
for _, entry in ipairs(SETTINGS) do
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
-- the OPTIONS row does. The manager persists its own value; this is the one
-- that has to follow it.
if stagedBattles() then OverworldBattle.forceOG() end
-- and DAYTIME changed from the manager's page while FULL owns it snaps
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
-- not, and FULL's pin must hold against both
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
end)
-- ------- keeping the geometry in step with the world
@@ -416,7 +700,184 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
mod.exports.version = "1.0.6"
-- ------- the terrain solver needs the whole map registry
--
-- lib/Elevation.lua cuts the connected overworld into plateaus, which
-- takes every map's blocks and connections at once -- not just the one
-- being walked. The engine keeps that registry in main.lua's `Game`,
-- which is a LOCAL there and reachable from no mod, so it arrives here
-- instead: `mods.loaded` carries the merged dataset, and it is the only
-- moment the whole of it is handed over. Without this the solver found
-- no data, answered "no field" for every map, and the world stayed as
-- flat as it ever was -- silently, which is the part that cost a while.
mod.events:on("mods.loaded", function(payload)
local data = payload and payload.data
if data and data.maps then
V.require("Elevation").install(data)
end
end)
-- ------- rows come and go, 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 changes the LIST: crossing FULL,
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
-- 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 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
local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil
end
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
local wasOn = idAt(self, self.index)
inner(self, dt)
local after = Pipelines.level("voxel")
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
-- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
-- otherwise slide the cursor onto the row under the one just used.
for i = 1, #self.rows do
if wasOn and idAt(self, i) == wasOn then self.index = i; break end
end
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)
-- ------- and the way back out
--
-- The engine wipes INTO a battle with one of the original's eight transitions
-- and cuts straight OUT of it. That cut is between two very different cameras
-- in this mode, so while voxel mode is on the battle fades out, closes behind
-- the black, and the map fades up. The two seams it needs -- BattleState:finish
-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua.
--
-- Declared as a transitions record rather than a constant in that file, so the
-- fade is retunable in data exactly like the eight wipes it answers, and a total
-- conversion can make it as long or as short as its own pacing wants.
mod.content.transitions:register(BattleExit.ID, {
frames = BattleExit.FRAMES,
})
BattleExit.install()
-- ------- and the hour on the flat world
--
-- The clock reaches the diorama through the voxel shader's own tint uniform,
-- which the 2D tile path never runs -- so with the mode off, the same evening
-- that fell on the diorama left the flat world at permanent noon. One clock,
-- two worlds, one of them ignoring it. DayTint paints the same multiply over
-- the composited flat world, between the world blit and the UI blit; the
-- reasoning for that exact instant is in the file.
DayTint.install()
-- ------- what time it is
--
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
-- time it is in Kanto is a fact about that journey, like where the player is
-- standing. Written on the engine's save.writing event -- the moment before
-- the bytes hit disk -- and read back whenever a save is opened or begun. A
-- save with no clock in it starts at day; that is DayNight.restore's
-- fallback, and also the DAYTIME row's own default.
mod.events:on("save.writing", function()
DayNight.store()
end)
mod.events:on("save.loaded", function()
DayNight.restore()
-- a save written before this mod was installed can carry TILT or GBC FX
-- switched on, and their rows are not there to switch them back off (see
-- pinEngineFx). Answered here rather than only when the menu opens, so a
-- player who never opens it is not left playing under one.
pinEngineFx()
end)
mod.events:on("save.created", function()
DayNight.restore()
pinEngineFx()
end)
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
-- eternal "DAY" until a mod answers here; answering it hands the period to
-- the map.palette hook (ctx.tod) and music.select, so a palette or music
-- pack keyed to night works with this mod's clock for free. next() first: a
-- mod loaded before this one that already moved the time keeps its answer.
mod.hooks:wrap("world.tod", function(next, tod, ctx)
local out = next(tod, ctx)
if out ~= tod then return out end
return DayNight.tod()
end)
mod.exports.version = "1.4.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
+7 -4
View File
@@ -1,17 +1,20 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.0.6",
"version": "1.4.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": "0.0.0-dev || >=0.1.28 <2.0.0",
"game_version": "0.0.0-dev || >=0.1.37 <2.0.0",
"priority": 100,
"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. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 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.",
"github": "DramaticShape/DramaticShapeVoxelMod"
}
+23 -8
View File
@@ -1,33 +1,48 @@
-- 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",
"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",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
"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",
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
},
added = {
"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",
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
"a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
"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",
"water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew",
"WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water",
"a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line",
"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",
},
},
credits = {
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
},
compat = { engine = ">=0.1.28 <2.0.0", modApi = 2 },
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
}
+233
View File
@@ -0,0 +1,233 @@
-- Driver: choose and photograph one battle arena per map.
--
-- data/battle_arenas.lua holds one authored spot per area. This is what
-- authors it: for every map a battle can happen on, it asks BattleArena for
-- the arena nearest the map's middle that its clearance test says both mons
-- can be SEEN in, stages a real battle there, and takes one screenshot.
--
-- Two outputs. A `PICK` line per map, ready to paste into the data file, and
-- a PNG per map to look at -- because the clearance test answers a geometry
-- question ("nothing tall on the sightline") and the actual question is
-- whether the picture reads. Grass and flowers around a mon's feet are fine
-- and wanted; a body cut in half by a wall is not, and only an eye catches
-- the difference.
--
-- SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
-- centred in the map's biggest body of it, which is what the surf routes
-- want. SHOT_DIR must already exist -- the capture writes with io.open,
-- which does not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
game.save.player.name = "RED"
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local Arena = lib.require("BattleArena")
local Battles = lib.require("OverworldBattle")
-- ------- staging out on the water
--
-- A surf route's land is a rim of beach round the edge of the map, so the
-- ordinary search always picks the rim and the fight happens on sand at
-- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
-- counts as ground, and the search starts from the middle of the map's
-- BIGGEST body of water rather than the middle of the map, so the arena
-- lands out in the open sea instead of against the first shoreline it
-- finds.
--
-- Biggest body, not all water at once: a map with a lake and an ocean has
-- a centroid between them that is on neither, and the arena would be
-- pinned to whichever shore that landed nearest.
local function waterCentre(map)
local w, h = map.widthCells, map.heightCells
local seen, best = {}, nil
for y0 = 0, h - 1 do
for x0 = 0, w - 1 do
if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
-- one connected body, flooded from this cell
local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
seen[y0 * w + x0] = true
while #stack > 0 do
local cell = table.remove(stack)
local cx, cy = cell[1], cell[2]
n, sx, sy = n + 1, sx + cx, sy + cy
for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
local nx, ny = cx + d[1], cy + d[2]
local key = ny * w + nx
if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
and map:isWaterCell(nx, ny) then
seen[key] = true
stack[#stack + 1] = { nx, ny }
end
end
end
if not best or n > best.n then
best = { n = n, x = sx / n, y = sy / n }
end
end
end
end
return best
end
-- Which maps a battle can actually happen on: anything with a wild
-- encounter table or an object that fights. Everything else -- a shop
-- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
-- that never happens there.
--
-- The encounter tables are their OWN registry keyed by map id, not a field
-- on the map record. An earlier cut of this read `def.encounters`, which is
-- always nil -- so the list silently narrowed to trainer maps only and
-- every route, cave and Safari zone fell out of it. Nothing failed; the
-- tool just quietly stopped offering to author the places wild battles
-- actually happen.
local function fights(id, def)
local enc = game.data.encounters and game.data.encounters[id]
if enc then
for _, kind in ipairs({ "grass", "water" }) do
local t = enc[kind]
if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
return true
end
end
end
for _, obj in ipairs(def.objects or {}) do
if obj.trainer or obj.trainerClass or obj.species then return true end
end
return false
end
local ids = {}
local explicit = os.getenv("ARENA_MAPS")
if explicit and explicit ~= "" then
for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
else
for id, def in pairs(game.data.maps) do
if type(id) == "string" and type(def) == "table" and fights(id, def) then
ids[#ids + 1] = id
end
end
table.sort(ids)
end
local from = tonumber(os.getenv("ARENA_FROM") or "") or 1
local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids
U.log(("%d battle maps; doing %d..%d"):format(#ids, from,
math.min(#ids, from + count - 1)))
-- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter
-- picks is the authoritative answer to "which areas need an arena", so it
-- has to be readable without sitting through a capture -- that is how the
-- bug above went unnoticed.
if count <= 0 then
for i = 1, #ids, 8 do
U.log("LIST " .. table.concat(ids, ",", i, math.min(#ids, i + 7)))
end
U.log("done -- listing only")
return
end
for i = from, math.min(#ids, from + count - 1) do
local id = ids[i]
local def = game.data.maps[id]
local cx = math.floor(def.width) -- the middle, in cells
local cy = math.floor(def.height)
-- the pick is made against the MAP, before anything is staged, so a map
-- with no clear arena at all is reported rather than quietly given a bad
-- one
local ok, err = pcall(function()
U.teleport(game, id, 1, 1, "down")
end)
if not ok then
U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
else
local map = game.overworld.map
local clear, any
-- ARENA_AT=x,y[,shape] photographs one specific spot instead of the
-- picked one, which is how a map the automatic choice got wrong is
-- re-aimed by hand and checked in the same loop.
-- ARENA_MAP=OTHER_ID stages on another floor of the same cave or
-- building, for a map that has nowhere of its own to put a fight
local at = os.getenv("ARENA_AT")
local onMap = os.getenv("ARENA_MAP")
if at and at ~= "" and (explicit and explicit ~= "") then
local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$")
if ax then
-- forced through the authored-entry seam, so what gets staged is
-- exactly what was asked for rather than whatever the search
-- would have picked from the player's cell
local onCam = os.getenv("ARENA_CAM")
Arena.setOverride(id, {
x = tonumber(ax), y = tonumber(ay),
shape = (ashape ~= "" and ashape) or "wide",
map = (onMap ~= "" and onMap) or nil,
cam = (onCam and onCam ~= "" and onCam) or nil,
})
any = Arena.find(map, 0, 0, false)
clear = any and Arena.clearance(any.map or map, any) or false
end
end
local surf = os.getenv("ARENA_SURF")
surf = surf ~= nil and surf ~= "" and surf ~= "0"
if not any then
local ox, oy = cx, cy
if surf then
local sea = waterCentre(map)
if sea then
ox, oy = sea.x, sea.y
U.log(("SEA %s: biggest body is %d cells, centre %.1f,%.1f")
:format(id, sea.n, sea.x, sea.y))
else
U.log("SEA " .. id .. ": no water on this map")
end
end
clear = Arena.search(map, ox, oy, surf, true)
any = clear or Arena.search(map, ox, oy, surf)
end
if not any then
U.log(("NONE %s -- no arena of either shape"):format(id))
else
local onOther = any.map and any.map.id ~= id and any.map.id or nil
U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s },%s")
:format(id, onOther and ("map = %q, "):format(onOther) or "",
any.x, any.y, any.shape,
any.cam and (", cam = %q"):format(any.cam) or "",
clear and "" or " -- OBSTRUCTED: no clear spot"))
-- stand the player on the arena so the staged battle uses it, then
-- fight something there and photograph the result
game.overworld.player.cellX = any.playerCell[1]
game.overworld.player.cellY = any.playerCell[2]
U.wait(90) -- let the meshes land
local battle = BattleState.newWild(game, "NIDORINO", 20)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local got = Battles.arena()
U.log(("SHOT %s at %s,%s"):format(id,
tostring(got and got.x), tostring(got and got.y)))
U.shot(game, ("%s/%s.png"):format(DIR, id:lower()))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(6)
end
end
end
U.log("done -- " .. DIR)
end
+130
View File
@@ -0,0 +1,130 @@
-- Driver: screenshot an overworld battle against eight random trainers.
--
-- The mode's whole claim is that a fight reads as happening on the map, so
-- what has to be looked at is a spread of maps: open route, town, forest,
-- cave, gym floor. Each one gets the same three beats -- the menu (both HUD
-- panels up over whatever ground they landed on), a move animation, and the
-- frame a few seconds later, which is where the parallax drift shows.
--
-- The eight are drawn with a fixed seed, so a re-run after a tweak produces
-- the same eight battles and the shots compare directly.
--
-- SHOT_DIR=.scratchpad POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/battle_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local SEED = tonumber(os.getenv("SHOT_SEED") or "") or 20260727
love.math.setRandomSeed(SEED)
-- Somebody to fight with. Two very different back pics, so a pin that is
-- wrong for one silhouette and right for another cannot hide.
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
game.save.player.name = "RED"
-- Where to stage them: outdoor, wooded, urban, underground, indoor. Any id
-- the merged dataset does not have is dropped rather than guessed at.
local PLACES = {
{ "ROUTE_1", 5, 8 },
{ "VIRIDIAN_FOREST", 12, 20 },
{ "PALLET_TOWN", 5, 6 },
{ "MT_MOON_1F", 12, 12 },
{ "CERULEAN_CITY", 10, 12 },
{ "ROUTE_25", 12, 5 },
{ "PEWTER_GYM", 4, 8 },
{ "ROUTE_4", 10, 5 },
{ "VIRIDIAN_CITY", 20, 20 },
{ "ROUTE_3", 10, 5 },
}
local places = {}
for _, p in ipairs(PLACES) do
if game.data.maps[p[1]] then places[#places + 1] = p end
end
-- every trainer class the merged data carries, in a stable order so the
-- seed picks the same ones every run
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
U.log(("%d trainer classes, %d places, seed %d")
:format(#classes, #places, SEED))
local Battles = nil
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if lib then Battles = lib.require("OverworldBattle") end
if not Battles then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
end
local function label(place, class)
local arena = Battles and Battles.arena()
if not arena then
return ("%s / %s -- NO ARENA (plain battle screen)")
:format(place[1], class)
end
return ("%s / %s -- %s arena at cell %d,%d; enemy %d,%d player %d,%d")
:format(place[1], class, arena.shape, arena.x, arena.y,
arena.enemyCell[1], arena.enemyCell[2],
arena.playerCell[1], arena.playerCell[2])
end
for i = 1, 8 do
-- the TRAINERS are the random part; the places are walked in order, so
-- one run covers open route, town, forest, cave and gym floor rather
-- than landing on the same meadow eight times
local place = places[(i - 1) % #places + 1]
local class = classes[love.math.random(#classes)]
local rec = game.data.trainers[class]
local partyIndex = love.math.random(#rec.parties)
local tag = ("%02d_%s_%s"):format(i, place[1]:lower(), class:lower())
U.teleport(game, place[1], place[2], place[3], "down")
-- let the neighbourhood's meshes land before the fight starts, so the
-- first battle frame is not the flat fallback
U.wait(90)
local battle = BattleState.newTrainer(game, class, partyIndex)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe, then the send-out chatter
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
U.log(label(place, class))
U.shot(game, ("%s/%s_1_menu.png"):format(DIR, tag))
-- FIGHT -> first move -> the animation
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(24)
U.shot(game, ("%s/%s_2_anim.png"):format(DIR, tag))
-- and a while later, where the drift has moved the world under them
U.wait(240)
U.shot(game, ("%s/%s_3_drift.png"):format(DIR, tag))
-- out of the battle and on to the next: pop whatever is above the
-- overworld rather than trying to play the fight to its end
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+109
View File
@@ -0,0 +1,109 @@
-- Driver: screenshot the day/night cycle.
--
-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
-- mid-blend moments of the running cycle, a battle staged under the night
-- sky, and a room at midnight -- which must look exactly like a room at
-- noon, because indoors the clock does not reach.
--
-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local DayNight = lib.require("DayNight")
local function setTime(value, clock)
DayNight.setting:sync(value)
if clock then DayNight.clock = clock end
DayNight.update(0)
end
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
-- straight to the top rung, whatever the persisted options say: pressing
-- the key would walk the ladder RELATIVE to wherever a previous run left it
local Pipelines = require("src.render.Pipelines")
Pipelines.setLevel("voxel", 5)
U.wait(150) -- let the camera ease over and the meshes land
-- ------- the four pins, one vantage
for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
setTime(phase)
U.wait(30) -- a couple of rig steps + palette settle
U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
end
-- ------- the running cycle, mid-blend
setTime("cycle", 560) -- day leaning into dusk
U.wait(10)
U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
setTime("cycle", 645) -- dusk falling into night
U.wait(10)
U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
-- ------- golden hour on open ground, long shadows
U.teleport(game, "ROUTE_1", 8, 12, "up")
U.wait(120)
setTime("cycle", 555) -- low western sun, shadows long and eastward
U.wait(30)
U.shot(game, DIR .. "/30_route1_low_sun.png")
-- ------- a fight staged under the night sky
setTime("night")
game.save.party = game.save.party or {}
if #game.save.party == 0 then
game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
end
-- any real trainer class: the dataset's ids vary by merge, so take the
-- first one in stable order rather than guessing a name
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)
local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
if battle then
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
U.shot(game, DIR .. "/40_night_battle.png")
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
-- ------- and a room, where midnight must change nothing
setTime("night")
if game.data.maps.REDS_HOUSE_1F then
U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
U.wait(90)
U.shot(game, DIR .. "/50_indoor_at_night.png")
end
-- ------- the forest, where only the TINT of it reaches: night falls
-- through the canopy, but there is no sky and the noon light stays put
setTime("night")
if game.data.maps.VIRIDIAN_FOREST then
U.teleport(game, "VIRIDIAN_FOREST", 17, 20, "up")
U.wait(120)
U.shot(game, DIR .. "/51_forest_night.png")
setTime("day")
U.wait(30)
U.shot(game, DIR .. "/52_forest_day.png")
end
setTime("day")
U.log("done -- " .. DIR)
end
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
-- Driver: one overworld-battle screenshot per species, the mon fighting
-- ITSELF -- its back pic on the player's mark and its front pic on the
-- enemy's, so a single frame shows both sprites the 3D mode draws for it.
--
-- The point is a visual sweep for pic glitches (holes the paper-fill missed,
-- a silhouette cut wrong, a pin that leaves the mon floating), so every shot
-- is staged identically: same map, same cells, same beat -- the battle menu,
-- both HUD panels up. Whatever differs between two shots is the mon.
--
-- SHOT_DIR=.scratchpad/mon_shots \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mon_shots.lua love .
--
-- Files land as NNN_species.png in dex order.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/mon_shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- every real species the merged data carries, walked in dex order
local species = {}
for id, def in pairs(game.data.pokemon) do
if type(id) == "string" and type(def) == "table"
and def.dex and def.dex >= 1 and def.dex <= 151 then
species[#species + 1] = { id = id, dex = def.dex }
end
end
table.sort(species, function(a, b) return a.dex < b.dex end)
U.log(("%d species"):format(#species))
game.save.player.name = "RED"
for _, s in ipairs(species) do
-- level 50 both sides: high enough that nothing about the staging is
-- species-specific, and a wild battle never awards exp off a menu shot
game.save.party = { Pokemon.new(game.data, s.id, 50) }
U.teleport(game, "ROUTE_1", 5, 8, "down")
-- let the neighbourhood's meshes land so the first battle frame is the
-- real arena rather than the flat fallback
U.wait(60)
local battle = BattleState.newWild(game, s.id, 50)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe, then tap through "Wild X appeared!" and the send-out until
-- the battle MENU is actually up -- a fixed tap count lands on whatever
-- beat the intro happened to be on, which is how a shot ends up with the
-- trainer still standing where the mon should be
U.wait(70)
for _ = 1, 200 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
if battle.phase ~= "menu" then
U.log(("STUCK before menu: %s (phase %s)"):format(s.id, tostring(battle.phase)))
end
-- let the send-out slide/ball beat finish so the mon is standing still
U.wait(40)
U.shot(game, ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower()))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log("done -- " .. DIR)
end
+50
View File
@@ -0,0 +1,50 @@
-- Probe: one overworld battle, held on the menu beat, shot large.
--
-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love .
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")
love.math.setRandomSeed(20260727)
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
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
U.log("DRAMATIC_SHAPE lib:", tostring(lib))
local Battles = lib and lib.require("OverworldBattle")
U.log("OverworldBattle:", tostring(Battles))
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(120)
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("class:", classes[1])
local battle = BattleState.newTrainer(game, classes[1], 1)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local arena = Battles and Battles.arena()
U.log("arena:", arena and arena.shape or "none")
U.shot(game, DIR .. "/probe_menu.png")
for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end
U.wait(90)
U.shot(game, DIR .. "/probe_menu2.png")
U.log("done -- " .. DIR)
love.event.quit()
end
+63
View File
@@ -0,0 +1,63 @@
-- Driver: dump the exact pic textures a live 3D battle draws, per stage --
-- the sprite as loaded (raw) and what picImage hands the billboard after the
-- palette bake and BattlePics' paper fill (final). Diagnostic for pics that
-- render with holes: whichever stage the transparency first appears in is
-- the stage that made it.
--
-- SHOT_DIR=.scratchpad/pic_dump \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/pic_dump.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/pic_dump"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local function save(img, path)
if not img then U.log("NIL image for " .. path) return end
local w, h = img:getDimensions()
local g = love.graphics
local prev = g.getCanvas()
local canvas = g.newCanvas(w, h, { dpiscale = 1 })
g.setCanvas(canvas)
g.clear(0, 0, 0, 0)
g.setBlendMode("replace", "premultiplied")
g.setColor(1, 1, 1, 1)
g.draw(img, 0, 0)
g.setCanvas(prev)
g.setBlendMode("alpha")
local f = assert(io.open(path, "wb"))
f:write(canvas:newImageData():encode("png"):getString())
f:close()
end
local SPECIES = os.getenv("PIC_SPECIES")
local list = {}
if SPECIES then
for id in SPECIES:gmatch("[^,%s]+") do list[#list + 1] = id:upper() end
else
list = { "PIKACHU", "SEEL", "BULBASAUR", "MEWTWO" }
end
for _, id in ipairs(list) do
game.save.party = { Pokemon.new(game.data, id, 50) }
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(30)
local battle = BattleState.newWild(game, id, 50)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(80)
local lo = id:lower()
save(battle.enemy.sprite, ("%s/%s_front_raw.png"):format(DIR, lo))
save(battle:picImage(battle.enemy.sprite), ("%s/%s_front_final.png"):format(DIR, lo))
save(battle.player.sprite, ("%s/%s_back_raw.png"):format(DIR, lo))
save(battle:picImage(battle.player.sprite), ("%s/%s_back_final.png"):format(DIR, lo))
U.log("dumped " .. id)
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(5)
end
U.log("done -- " .. DIR)
love.event.quit()
end
+218
View File
@@ -0,0 +1,218 @@
-- Driver: propose and photograph several battle arenas for ONE map.
--
-- data/battle_arenas.lua holds a single authored spot per area, chosen by
-- arena_pick's nearest-to-the-middle search and then looked at. This is the
-- other half of that job: when the shipped spot is up for review, it lays out
-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread
-- along the map so the shortlist is places rather than neighbours -- and
-- stages a real battle in each so they can be compared by eye.
--
-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love .
--
-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph.
-- One `CAND` line per shot, ready to paste into the data file, plus a PNG
-- named for its corner and shape.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates"
local MAP = os.getenv("CAND_MAP") or "ROUTE_1"
local WANT = tonumber(os.getenv("CAND_N") or "") or 5
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")
U.teleport(game, MAP, 1, 1, "down")
local map = game.overworld.map
U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells))
-- ------- every arena the map can offer
--
-- BattleArena.search answers "the nearest one", which is the wrong question
-- for a shortlist -- it returns one spot and hides the rest. So walk the
-- same grid ourselves and keep them all, tagged with whether the pair would
-- actually be SEEN there (Arena.clearance), because an obstructed spot is
-- not a candidate no matter how good the ground looks.
--
-- The map's outermost cells are its CONNECTION BORDER -- the strip the
-- neighbouring map is drawn into, walkable so the player can step across.
-- Ground there passes every test and is still the wrong answer: a fight
-- staged on it happens at the edge of the world with the border ring's tree
-- wall at the mons' backs, and every spot in the strip looks like every
-- other one. CAND_MARGIN keeps the shortlist on the route proper.
local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2
local cands = {}
for _, shape in ipairs(Arena.SHAPES) do
for y = MARGIN, map.heightCells - shape.h - MARGIN do
for x = MARGIN, map.widthCells - shape.w - MARGIN do
local fits = true
for cy = y, y + shape.h - 1 do
for cx = x, x + shape.w - 1 do
if not Arena.openCell(map, cx, cy, false) then fits = false break end
end
if not fits then break end
end
if fits then
local a = Arena.at(x, y, shape.id)
if a and Arena.clearance(map, a) then
cands[#cands + 1] = { x = x, y = y, shape = shape.id,
mx = x + (shape.w - 1) / 2,
my = y + (shape.h - 1) / 2 }
end
end
end
end
end
U.log(("%d clear arenas on %s"):format(#cands, MAP))
if #cands == 0 then U.log("done -- nothing to propose") return end
for _, c in ipairs(cands) do
U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape))
end
-- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead
-- of the spread one. The spread is a first pass over ground the clearance
-- test approved, and that test measures terrain height along the sightline
-- only -- it has no opinion on a hedge sitting in the apron row between the
-- camera and the near mon, which is the failure that keeps turning up. So
-- the loop is: spread, look, then re-shoot the survivors and the
-- replacements by hand.
local explicit = os.getenv("CAND_AT")
if explicit and explicit ~= "" then
local list = {}
for spot in explicit:gmatch("[^;]+") do
local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$")
if x then
list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s }
else
U.log("BAD CAND_AT entry: " .. spot)
end
end
cands = list
WANT = #list
U.log(("%d spots given explicitly"):format(#list))
end
local function shapeOf(id)
for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end
end
for _, c in ipairs(cands) do
local s = shapeOf(c.shape)
c.mx = c.x + ((s and s.w or 1) - 1) / 2
c.my = c.y + ((s and s.h or 1) - 1) / 2
end
-- ------- thin them down to a shortlist that is actually a CHOICE
--
-- Adjacent corners are the same patch of ground shifted a cell, so a naive
-- top-N is five photographs of one place, and pure farthest-point selection
-- goes straight to the extremes -- which on a route means the ends, where
-- the ground is emptiest and the shots are least distinguishable.
--
-- So: spread along the route's LONG AXIS, one pick per band, and within a
-- band take the spot nearest the middle of the road. The road is measured
-- rather than assumed -- the median cross-axis position of everywhere a
-- fight fits IS the lane, on a map whose walkable ground is mostly lane.
local horizontal = map.widthCells > map.heightCells
local function along(c) return horizontal and c.mx or c.my end
local function across(c) return horizontal and c.my or c.mx end
local xs = {}
for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end
table.sort(xs)
local road = xs[math.ceil(#xs / 2)]
U.log(("road runs %s, centre of the lane is %s = %.1f")
:format(horizontal and "east-west" or "north-south",
horizontal and "y" or "x", road))
local lo, hi = math.huge, -math.huge
for _, c in ipairs(cands) do
lo, hi = math.min(lo, along(c)), math.max(hi, along(c))
end
-- an explicit shortlist is already the answer; the spread below would only
-- thin it, and its overlap rule would silently drop two spots deliberately
-- asked for a cell apart
local picked, taken = {}, {}
for band = 1, (explicit and explicit ~= "") and 0 or WANT do
-- band centres, not band edges: the first and last picks sit inside the
-- route rather than on its two connection mouths
local target = lo + (hi - lo) * (band - 0.5) / WANT
local best, bestScore
for _, c in ipairs(cands) do
if not taken[c] then
local da = along(c) - target
local dr = across(c) - road
-- distance from the band centre, plus a heavier penalty for being off
-- the lane; wide arenas are worth a detour, being the shot this mode
-- is framed for
local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0)
if not bestScore or score < bestScore then best, bestScore = c, score end
end
end
if best then
picked[#picked + 1] = best
-- everything overlapping the pick is off the table, so two bands whose
-- best spots touch cannot return the same patch of ground twice
for _, c in ipairs(cands) do
if math.abs(along(c) - along(best)) < 3
and math.abs(across(c) - across(best)) < 3 then
taken[c] = true
end
end
end
end
if #picked == 0 then picked = cands end
-- north to south (or west to east), so the filenames read along the route
table.sort(picked, function(a, b)
if along(a) ~= along(b) then return along(a) < along(b) end
return across(a) < across(b)
end)
for i, c in ipairs(picked) do
U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },")
:format(i, MAP, c.x, c.y, c.shape))
-- forced through the authored-entry seam, so what gets staged is exactly
-- this spot rather than whatever the search would pick from the player's
-- cell
Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape })
local staged = Arena.find(map, 0, 0, false)
if not staged then
U.log(("SKIP %d -- override did not stage"):format(i))
else
game.overworld.player.cellX = staged.playerCell[1]
game.overworld.player.cellY = staged.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 %d staged at %s,%s"):format(i, tostring(got and got.x),
tostring(got and got.y)))
U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png")
:format(DIR, i, MAP:lower(), c.x, c.y, c.shape))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(6)
end
end
Arena.setOverride(MAP, nil)
U.log("done -- " .. DIR)
end
+55
View File
@@ -0,0 +1,55 @@
-- Driver: prove the banded sky still paints from the ramp texture.
--
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
-- Android delivered only its first few slots and painted the rest of the sky
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
-- three things that swap could have broken, on the machine it CAN be checked
-- on: that the shader still compiles, that the ramp is built and is one texel
-- per band, and that what lands on screen is still a gradient that pales
-- downward rather than a flat plate or a black one.
--
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
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 Sky = lib.require("Sky")
local DayNight = lib.require("DayNight")
-- which copy of the mod is actually live: only the ramp build has this
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
require("src.render.Pipelines").setLevel("voxel", 5)
U.wait(150)
for _, phase in ipairs({ "day", "dusk", "night" }) do
DayNight.setting:sync(phase)
DayNight.update(0)
U.wait(30)
local bands = Sky.bands()
local ramp = Sky._rampFor and Sky._rampFor(bands)
local w = ramp and ramp:getWidth() or -1
U.log(("%s: %d bands, ramp %dx%d"):format(
phase, #bands, w, ramp and ramp:getHeight() or -1))
-- one texel per band is the whole contract: the shader divides by `count`
-- and samples texel centres, so a ramp of any other width samples between
-- bands or off the end
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
-- and the ramp is the ramp: the same table gives the same image back
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
U.shot(game, ("%s/%s.png"):format(DIR, phase))
end
DayNight.setting:sync("day")
U.log("done -- " .. DIR)
end
+175
View File
@@ -0,0 +1,175 @@
-- Driver: isolate the diagonal banding on flat lit geometry.
--
-- Shoots the SAME stand point four ways in ONE launch, so palette, camera
-- and framing are identical and the only variable is the sun pass:
--
-- _on shadows as shipped
-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it
-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off
--
-- and prints, from the frustum the run actually fitted, how much depth
-- slack each face orientation NEEDS against the slack it is given. A face
-- whose need exceeds the slack shadows itself in a moire -- acne -- which
-- reads as diagonal banding, since the ramp it aliases against runs along
-- the light's frame and the sun sits southeast.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec .
--
-- knobs (env):
-- ACNE_MAP map id (default VIRIDIAN_CITY)
-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall)
-- ACNE_LEVELS comma list of voxel levels (default "3")
-- ACNE_FLAT comma list of constant-bias values to compare against
-- (default "1" -- what shipped before SLOPE existed)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY"
local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "up"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing")
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local Mat4 = V.require("Mat4")
local levels = {}
for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do
levels[#levels + 1] = tonumber(n)
end
local flats = {}
for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do
flats[#flats + 1] = tonumber(n)
end
-- ---- what the frustum this run fitted asks of the bias, per face
local FACES = {
{ "top +Y", 0, 1, 0 },
{ "south +Z", 0, 0, 1 },
{ "east +X", 1, 0, 0 },
{ "west -X", -1, 0, 0 },
{ "north -Z", 0, 0, -1 },
{ "roof 45 N", 0, 0.7071, -0.7071 },
{ "roof 45 S", 0, 0.7071, 0.7071 },
}
local function report(tag)
local e = ShadowMap.extent
if not e then print("[acne] " .. tag .. ": no frustum yet") return end
local res = ShadowMap.res
local texX, texY, depth = e[1] / res, e[2] / res, e[3]
local d = ShadowMap.sunDir()
local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 })
local R = { view[1], view[2], view[3] }
local Up = { view[5], view[6], view[7] }
local F = { -view[9], -view[10], -view[11] }
print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f"
.. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)")
:format(tag, res, e[1], e[2], depth, texX, texY,
ShadowMap.slack, ShadowMap.bias))
for _, fc in ipairs(FACES) do
local n = { fc[2], fc[3], fc[4] }
local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3]
local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3]
local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3]
-- a face the sun cannot see never samples its own depth, so it
-- cannot alias -- FACE_SHADE darkens those and the map is moot
if nf < 0 then
-- the plane's depth gradient in light space, times the half-texel
-- the 2x2 filter reaches out on each axis
local need = 0.5 * (math.abs(nr / nf) * texX
+ math.abs(nu / nf) * texY)
print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s")
:format(fc[1], math.abs(nf),
math.sqrt((nr/nf)^2 + (nu/nf)^2), need,
need > ShadowMap.slack and "<-- ACNE" or "ok"))
else
print(("[acne] %s unlit (sun behind it)"):format(fc[1]))
end
end
end
local Zoom = require("src.render.Zoom")
Zoom.reset()
local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0)
for _ = 1, math.abs(steps) do
Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
end
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
do
local vw, vh = game.renderer:worldViewSize()
print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d")
:format(mapId, sx, sy, facing, vw, vh, Zoom.offset))
end
local shipped = Voxel3D.SHADOW_ALPHA
local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE
-- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid
-- on/off), so a band can be attributed to one of them rather than
-- guessed at. The grid is the other thing in this pass that draws
-- regular lines, and at a grazing angle it moires.
local VoxelGrid = V.require("VoxelGrid")
local matrix = os.getenv("ACNE_MATRIX") == "1"
local function shoot(name)
game.capturePath = ("%s/acne_%s.png"):format(DIR, name)
wait(4)
end
for _, level in ipairs(levels) do
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the camera tween
local label = Pipelines.levelLabel("voxel", level) or level
print(("[acne] --- level %s shadowsActive=%s")
:format(label, tostring(Voxel3D.shadowsActive())))
report("as shipped")
shoot(("%s_v%s_on"):format(mapId, label))
if matrix then
for _, grid in ipairs({ true, false }) do
VoxelGrid.sync(grid)
for _, sun in ipairs({ true, false }) do
Voxel3D.SHADOW_ALPHA = sun and shipped or 0
wait(8)
shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label,
grid and "1" or "0", sun and "1" or "0"))
end
end
Voxel3D.SHADOW_ALPHA = shipped
end
-- the sun pass still runs; the main pass simply stops reading it
Voxel3D.SHADOW_ALPHA = 0
wait(6)
shoot(("%s_v%s_off"):format(mapId, label))
Voxel3D.SHADOW_ALPHA = shipped
for _, b in ipairs(flats) do
ShadowMap.BIAS, ShadowMap.SLOPE = b, 0
ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias
wait(8)
report(("flat %.1f"):format(b))
shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p")))
end
ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope
ShadowMap.invalidate()
wait(6)
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[acne] done")
end
+246
View File
@@ -0,0 +1,246 @@
-- Driver: the A/B performance benchmark.
--
-- One scripted, deterministic session that exercises the three things the
-- mod is slow at, and writes the numbers to a JSON file so two runs can be
-- diffed:
--
-- 1. LOADING IN -- engaging the mode from flat: shader compiles,
-- canvas allocations, the first map's mesh, its
-- atlas bakes and its glass mask, all at once.
-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold
-- caches, then walking the SAME route again with
-- them warm. The gap between the two is the
-- complaint; closing it is the fix.
-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree
-- rung puts the horizon in frame, which triples the
-- sun frustum and hands the sky its disc to draw.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \
-- DS_PERF=1 BENCH_TAG=baseline lovec.exe .
--
-- knobs (env):
-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing
-- BENCH_TAG output name, ds_bench/<tag>.json (default "run")
-- BENCH_HOLD frames to walk per leg (default 900)
--
-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is
-- measuring the weather:
--
-- * VSYNC OFF. With it on every frame costs exactly one refresh interval
-- and the whole exercise reads as 16.7ms flat, saving or no saving.
-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun
-- angle, the shadow frustum and whether windows are lit -- so an
-- unpinned run compares two different scenes.
-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges
-- its frames to whichever map the script thought it was on. Stubbed on
-- the state class for this process only; nothing is written to disk.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local TAG = os.getenv("BENCH_TAG") or "run"
-- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that
-- means to reach Viridian needs about 1200 -- short of that the "new
-- area" the benchmark is named for never gets entered.
local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300)
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure")
return
end
local V = handle.lib
local Perf = V.require("Perf")
if not Perf.enabled then
print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing")
return
end
local loadBytes = V.loadBytes
local Structures = V.require("Structures")
local ChunkMesher = V.require("ChunkMesher")
local Buildings = V.require("Buildings")
local DayNight = V.require("DayNight")
local VoxelScene = V.require("VoxelScene")
-- module internals worth naming that the mod does not time itself
Perf.wrap(Structures, "forMap", "Structures.forMap")
Perf.wrap(Buildings, "build", "Buildings.build")
Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump")
Perf.wrap(VoxelScene, "render", "VoxelScene.render")
if love.window and love.window.setVSync then
pcall(love.window.setVSync, 0)
end
DayNight.setting:sync("day")
OverworldState.rollEncounter = function() return nil end
-- ---- the run ------------------------------------------------------
local function seg(name)
Perf.setSegment(name)
-- the frame that STRADDLES a segment boundary belongs to neither: it
-- carries the teleport, the level change or the report print that
-- opened it, and charging that to the new segment libels it
Perf.resync()
end
local function settle(frames)
Perf.setSegment(nil)
U.wait(frames or 60)
Perf.resync()
end
-- Hold a direction, attributing each frame to the map the player is
-- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of
-- the walk, so the segment has to follow the player rather than the
-- script's idea of where they are.
local function walk(dir, frames, prefix)
local last = nil
for _ = 1, frames do
local o = game.overworld
local id = o and o.map and o.map.id
if id ~= last then
last = id
seg(prefix .. ":" .. tostring(id))
print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id),
U.frame()))
end
-- press and RELEASE each frame, the way tests/voxel_perf_probe's seam
-- crossing does: a direction left held accumulates in pressQueue and
-- the walk stalls where a single tap would have stepped
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
game.input.state[dir] = false
end
local o = game.overworld
print(("[bench] %s ended on %s at cell (%d,%d)"):format(
prefix, tostring(o and o.map and o.map.id),
o and o.player and o.player.cellX or -1,
o and o.player and o.player.cellY or -1))
Perf.setSegment(nil)
end
print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes))
-- ORDER MATTERS. The cold walk has to be the first time this session
-- draws Route 1 and Viridian, so everything before it stays in Pallet
-- Town -- a map whose caches the walk does not depend on. Measuring a
-- "first entry" into a map an earlier segment already warmed is the one
-- way to make this whole benchmark lie.
-- 1. the flat reference: the game with this mod present but not
-- drawing. Every later number is only interesting against this one.
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
Pipelines.setLevel("voxel", 0)
Pipelines.setLevel("tiltshift", 0)
settle(90)
seg("flat")
U.wait(180)
-- 2. loading in: FULL is what a player picks first and it is the most
-- expensive configuration there is (tilt-shift to maximum, 3D
-- battles on). Measured from the frame the level changes, so it
-- carries the shader compiles, the canvas allocations, the first
-- mesh, the first atlas bake and the first glass scan together.
settle(60)
seg("engage.full")
Pipelines.setLevel("voxel", 1) -- FULL
U.wait(240)
Perf.setSegment(nil)
-- 3. the low camera. 75 degrees puts the horizon in frame; the rungs
-- below it are the control. Standing still, so what is measured is
-- the frame's own cost and not the walk's mesh streaming.
for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees
Pipelines.setLevel("voxel", rung)
settle(60) -- let the tween finish
seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1]))
U.wait(180)
Perf.setSegment(nil)
end
-- 3b. the same low camera at DUSK, which is where the sky costs most:
-- the horizon is in frame, so the banded region is at its tallest,
-- and the sun is low enough to be in it -- the disc only draws at
-- all when it is above the horizon point, so a midday run never
-- touches that code and would report it as free.
for _, when in ipairs({ "dusk", "night" }) do
DayNight.setting:sync(when)
settle(60)
seg("pitch:75:" .. when)
U.wait(180)
Perf.setSegment(nil)
end
DayNight.setting:sync("day")
settle(30)
-- 4/5. arriving somewhere new, at the rung that hurts.
--
-- Arrival is measured by LOADING each map rather than by walking into
-- it. Walking would be more lifelike, but Route 1's ledges make a held
-- direction stall against geometry, so a fixed frame count buys a
-- different amount of travel on every run -- and a benchmark whose
-- route drifts cannot compare two runs at all. A load is the same
-- arrival stripped of the travel: the map swaps, and the next frames
-- pay for its mesh, its structure analysis, its atlas bake and its
-- glass mask exactly as they do behind a door fade.
--
-- Then the identical list a second time. Every cost in the gap between
-- the two passes is a cache that was cold, and that gap IS the
-- complaint.
Pipelines.setLevel("voxel", 5) -- 75 degrees
local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22",
"VIRIDIAN_FOREST", "PEWTER_CITY" }
local DWELL = math.max(60, math.floor(HOLD / #TOUR))
-- Stand in the middle of each map, derived from its own def rather than
-- written down: a hardcoded cell that falls outside a map teleports the
-- player nowhere and the segment silently measures the previous map.
local function centreOf(id)
local def = game.data.maps and game.data.maps[id]
if not def then return nil end
return math.floor(def.width), math.floor(def.height)
end
local function tour(prefix)
for _, id in ipairs(TOUR) do
local cx, cy = centreOf(id)
if cx then
U.teleport(game, id, cx, cy, "up")
-- the segment opens on the frame AFTER the teleport, so the load
-- itself is not charged to the arrival it caused
seg(prefix .. ":" .. id)
U.wait(DWELL)
Perf.setSegment(nil)
else
print("[bench] skipping unknown map " .. id)
end
end
end
tour("first")
tour("revisit")
-- 6. streaming while walking: the one crossing that is reliably
-- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1
-- south into Pallet, which pulls a neighbour's meshes in mid-stride.
U.teleport(game, "ROUTE_1", 10, 34, "down")
settle(120)
walk("down", 240, "walk")
-- ---- the report ---------------------------------------------------
Perf.setSegment(nil)
Perf.printReport("bench " .. TAG)
Perf.write(TAG, {
tag = TAG,
loadBytes = loadBytes,
hold = HOLD,
texturememory = Perf.texturememory or 0,
canvases = Perf.canvases or 0,
images = Perf.images or 0,
})
print("[bench] done")
end
+245
View File
@@ -0,0 +1,245 @@
-- Figures: a person drawn INTO furniture, cut out by an authored pixel
-- mask and stood up on top of it (TileShape.figures / Structures.
-- buildFigures). The Pokemon Center's seated man is the case the feature
-- exists for, so this drives the real profile entry over a hand-built
-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08,
-- as every Center places it:
--
-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN,
-- y=9 52 53 60 27 57/60 floor he overhangs east onto,
-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base
-- y=11 42 43 26 27
--
-- No love, no GPU and no pixel access: a figure is authored rather than
-- detected, which is exactly what lets it build headless.
--
-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader)
local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
local V = { path = ROOT }
local function chunkFor(rel)
local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing")
local src = f:read("*a")
f:close()
return assert(load(src, "@" .. ROOT .. "/" .. rel))
end
local modules, dataFiles = {}, {}
function V.require(name)
if modules[name] == nil then
modules[name] = chunkFor("lib/" .. name .. ".lua")(V)
end
return modules[name]
end
function V.data(name)
if dataFiles[name] == nil then
dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V)
end
return dataFiles[name]
end
local TileShape = V.require("TileShape")
local Structures = V.require("Structures")
-- ------- the authored mask parses
local figs = TileShape.figures("POKECENTER")
T.check(type(figs) == "table" and #figs == 1,
"POKECENTER carries exactly one figure")
local fig = figs[1]
T.eq(fig.w, 3, "the figure is three tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans")
T.check(fig.class == nil,
"a figure carries no class -- it is always a flat sprite card")
local W = fig.w * 8
local function on(lx, ly) return fig.mask[ly * W + lx] == true end
-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the
-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7
-- only, rows 3-7 are his hair in both
T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1),
"the arm's own top rows stay with the couch")
T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2")
for ly = 3, 7 do
T.check(on(6, ly) and on(7, ly),
"the back of his head fills both columns at row " .. ly)
end
-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND
-- the right side of his face, and masking it out by shade slit his cheek
for ly = 5, 8 do
T.check(on(15, ly),
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
end
T.eq(fig.tiles[1], 36, "it starts at the couch's west arm")
T.eq(fig.tiles[2], 37, "then the tile his head is drawn in")
T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off")
T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion")
T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)")
T.eq(fig.under[6], 26, "and (60 -> 26)")
-- the mask is one connected figure: an overhang that floats free of him
-- is the failure this feature exists to avoid
local seen, first = {}, nil
for i in pairs(fig.mask) do first = first or i end
local stack, reached = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
reached = reached + 1
local x, y = i % W, math.floor(i / W)
for dy = -1, 1 do
for dx = -1, 1 do
local nx, ny = x + dx, y + dy
local ni = ny * W + nx
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W
and fig.mask[ni] and not seen[ni] then
seen[ni] = true
stack[#stack + 1] = ni
end
end
end
end
T.eq(reached, fig.n, "the mask is a single connected figure")
-- ------- the couch block, as the Centers place it
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
local COUNTER = { class = "counter", h = 8, art = "upright",
flat = false, authored = true }
local GROUND = { class = "ground", h = 0, art = "flat",
flat = true, authored = false }
local BLOCK = { 36, 37, 57, 11,
52, 53, 60, 27,
38, 39, 54, 11,
42, 43, 26, 27 }
local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true,
[42] = true, [43] = true, [52] = true, [53] = true }
local function scene()
local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
ground = {}, runs = {} }
for i = 1, 16 do
local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4)
local tile = BLOCK[i]
S.tileAt[keyOf(tx, ty)] = tile
S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND
end
return S
end
-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn
-- sitting on it), the floor cell east of it is walkable
local map = {
tileset = { id = "POKECENTER", tilesPerRow = 16,
imageWidth = 128, imageHeight = 48 },
isWalkableCell = function(_, cx) return cx >= 1 end,
}
-- ------- he comes off the couch
local S = scene()
Structures.buildFigures(S, map, 0, 3, 8, 11)
T.eq(#S.figures, 1, "one figure card was built")
local card = S.figures[1]
T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else")
T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now")
T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion")
T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too")
T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again")
T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it")
T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched")
T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base")
-- the couch keeps its box: a figure changes ART, never class
T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
"his tiles are still the couch's half-cell box")
T.check(S.skip[keyOf(1, 8)] ~= true,
"and are not skipped -- the couch still renders there")
-- ------- the card is flat, and stands at its feet
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(card.quads) do
for c = 1, 4 do
local p = q[c]
minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1])
minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2])
minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3])
end
end
T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)")
T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab")
T.eq(minY, 0, "local space: his feet are the card's origin")
T.eq(maxY, 16, "and he is his drawn 16px tall")
T.eq(minX, 0, "his west edge is the card's origin too")
T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang")
-- ------- and where VoxelScene stands it
T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor")
T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6")
T.eq(card.wz, 76,
"and pivoting at the middle of the tile row his feet are drawn in")
-- ------- a map that does not draw him builds nothing
local other = scene()
other.tileAt[keyOf(1, 8)] = 40
Structures.buildFigures(other, map, 0, 3, 8, 11)
T.eq(#other.figures, 0, "no match, no figure")
T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted")
-- ------- and it never fires twice on the same drawing
local twice = scene()
Structures.buildFigures(twice, map, 0, 3, 8, 11)
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
-- the bottom of the plant block, so the ordinary vote (shades touching the
-- drawing's own bounding box) read "dark" as background and drained every
-- olive pixel in the plant.
local bgRules = TileShape.propBg("POKECENTER")
T.check(type(bgRules) == "table", "POKECENTER names prop background shades")
for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do
local set = bgRules and bgRules[tile]
T.check(type(set) == "table" and set.light and set.white
and not set.dark and not set.black,
"plant tile " .. tile .. ": light/white are background, dark is the pot")
end
-- and it is scoped: the healing consoles' screens and the PC keep the
-- ordinary vote, because they want the opposite call on those same shades
for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do
T.check(bgRules[tile] == nil,
"tile " .. tile .. " keeps the ordinary background vote")
end
-- a tileset with no prop_bg at all answers nil rather than an empty table,
-- so Structures can skip the lookup entirely
T.check(TileShape.propBg("CAVERN") == nil,
"a tileset that names none answers nil")
T.finish("DRAMATIC_SHAPE figures")
+346
View File
@@ -0,0 +1,346 @@
-- Driver: the pixel-identity gate for performance work.
--
-- Every optimization in this mod's performance pass claims the frame comes
-- out the same. This driver is what makes that claim checkable rather than
-- asserted: it renders a fixed set of scenes -- several maps, indoors and
-- out, at every camera rung, in every display mode -- and writes one PNG
-- per scene. Run it before a change and after it, hash the two directories,
-- and any file whose hash moved is a scene the change altered.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \
-- SHOT_DIR=<dir> AB_TAG=before lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory (created if missing) (default "shots/ab")
-- AB_TAG subdirectory under SHOT_DIR (default "before")
-- AB_MODES display modes to sweep, comma list (default all four)
--
-- DETERMINISM is the whole game here, because a shot that differs for a
-- reason other than the change under test makes the gate useless:
--
-- * the day/night clock is PINNED (an unpinned sky is a different sky
-- every second, and it drives the sun angle and the shadow frustum);
-- * the animated tile slots ride the engine's 60Hz counter, so every
-- scene is reached after the SAME number of frames from the same
-- starting state, and the water is at the same point in its roll;
-- * levels are set through Pipelines.setLevel, never the hotkey, so the
-- run cannot write the player's options;
-- * encounters are stubbed off -- a wild battle would replace the scene
-- the shot is named for.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local ROOT = (os.getenv("SHOT_DIR") or "shots/ab")
.. "/" .. (os.getenv("AB_TAG") or "before")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
OverworldState.rollEncounter = function() return nil end
-- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is
-- seeded differently every launch -- so two runs of this driver put the
-- same townsfolk in different places and every shot with a person in it
-- differs for a reason that has nothing to do with the change under
-- test. Freeze them: `frozen` is the flag the NPC's own update already
-- honours, and an NPC mid-step still finishes it, so the settle below
-- lands on a still scene. They are still POSED and still drawn, so the
-- billboard, its lean and its shadow are all still under test.
local NPC = require("src.world.NPC")
if not NPC.dramaticShapeAbFreeze then
local inner = NPC.update
function NPC:update(...)
self.frozen = true
return inner(self, ...)
end
NPC.dramaticShapeAbFreeze = true
end
pcall(love.math.setRandomSeed, 20260730)
-- Freeze the tile-animation clock, on BOTH routes to it.
--
-- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the
-- same speed on a 60Hz and a 144Hz panel), which means the step a shot
-- catches depends on how fast the machine got there rather than on
-- anything the run did. Stubbing tick pins the counter the flat tile
-- layer reads.
--
-- The mod reads the SAME counter but through its own chain
-- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports
-- one, else the local off tick's upvalues, else -- and this is the trap
-- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it
-- ALONE knocks the mod onto the wall-clock fallback and makes the
-- flowers drift between two otherwise identical runs. Exporting a
-- constant animFrame takes the first branch and pins that route too.
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
-- The scenes. Chosen for what each one can BREAK, not for looks:
-- ROUTE_1 open ground, grass billboards, a long view north --
-- the case a shadow-frustum or culling change moves
-- VIRIDIAN_CITY buildings, window panes (the glass mask), signs
-- PALLET_TOWN the seam with Route 1: neighbour meshes and ring
-- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion
-- REDS_HOUSE_1F indoors: no sky, no sun, authored figures
-- PEWTER_CITY a second tileset with its own atlas bake
local SCENES = {
{ id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" },
{ id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" },
{ id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" },
{ id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" },
{ id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" },
{ id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" },
}
-- OFF is in the list deliberately: a change that speeds the 3D path up
-- must not have touched the flat one either. Then the three camera
-- rungs, 75 last because it is the low camera this performance work is
-- aimed at.
--
-- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings
-- PRESET, not a render path: it sets tilt-shift to maximum, flattens the
-- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME
-- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync),
-- which overrides this driver's pinned clock and makes every shot after
-- it depend on the wall clock. It also persists all of that, so one run's
-- FULL changes the options the NEXT run starts from. What FULL renders is
-- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers
-- exactly.
local RUNGS = {}
for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do
RUNGS[#RUNGS + 1] = tonumber(n)
end
local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0)
-- AB_SHADOW=0 renders with the sun pass's contribution turned off
-- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A
-- bisection tool: when a set of shots will not reproduce, this says
-- whether what is moving is in the shadow map or somewhere else.
if os.getenv("AB_SHADOW") == "0" then
V.require("Voxel3D").SHADOW_ALPHA = 0
end
-- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic`
-- are the SGB paths this mod bakes an atlas for, `gbc` is the shared
-- default, and `redpp` is the one that rebakes an atlas PER MAP -- four
-- genuinely different routes through TerrainAtlas.
local MODES = {}
for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do
MODES[#MODES + 1] = m
end
-- Two times of day, because half the shader only runs in one of them:
-- the window lamps, the moon disc and the night tint are all dark-only,
-- and the glint sweep and the sun disc are day-only.
local TIMES = { "day", "night" }
local shots, missed = 0, 0
-- U.shot's own mkdir is the POSIX one, which cmd.exe does not
-- understand, and a missing directory makes every capture vanish
-- silently. Try both spellings once, up front.
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
-- A capture that always costs the SAME number of frames.
--
-- U.shot spins up to 120 frames waiting for the capture to land, which
-- is right for a screenshot and wrong for this: the animated tile slots
-- (water rolling, flowers opening) ride the engine's frames-since-boot
-- counter, so a scene reached after a different number of frames renders
-- its water at a different point in the roll and the shot differs for a
-- reason no change caused. A driver resume and a rendered frame are 1:1
-- here, so the capture lands on the next draw and a fixed budget is both
-- enough and constant.
local CAPTURE_FRAMES = 4
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
-- Wait for the scene to actually BE the scene the shot is named for.
-- Two things are still in motion after a teleport, and both are timed in
-- wall-clock seconds rather than frames, so "wait N frames" settles them
-- by a different amount on every machine and every run:
--
-- the build queue -- meshes are built on a per-frame time budget, so a
-- slower run captures a half-built neighbour;
-- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot
-- taken before it lands is at some arbitrary intermediate pitch.
--
-- Both are waited on by their own completion flag, then a short fixed
-- settle. The variable wait is harmless now that the animation clock is
-- frozen above -- otherwise it would move the water instead.
-- and the CAMERA, which is the subtle one. It eases toward the player
-- over wall-clock dt, so after a fixed wait it has covered a distance
-- that depends on how fast the machine ran -- and the sun pass is only
-- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's
-- shadow signature), so a frame caught mid-ease carries a shadow map
-- fitted for a slightly different camera than the one it is drawn with.
-- That is a real and deliberate tolerance in the mod, but it makes the
-- gate compare two arbitrary points inside it. Waiting for the camera
-- to stop moving entirely puts every shot at the same steady state.
local function cameraStill()
local o = game.overworld
local c = o and o.camera
if not c then return true end
local lx, ly, held = nil, nil, 0
for _ = 1, 300 do
if c.x == lx and c.y == ly then
held = held + 1
if held >= 10 then return true end
else
held = 0
lx, ly = c.x, c.y
end
U.wait(1)
end
return false
end
local function settleBuild()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
cameraStill()
-- and then force one final sun pass at the settled camera. The map is
-- only redrawn when the camera crosses a quarter world pixel, so a
-- still camera holds whatever was drawn at the moment it last did --
-- correct to within that tolerance, but fitted from a position that
-- depends on where the easing happened to be, which differs by a few
-- hundredths of a pixel between runs and moves every shadow edge by a
-- shade or two. Forgetting the stamp redraws from the state the shot
-- is actually taken in, and two runs then agree exactly.
-- guarded so this driver can also be pointed at a build that predates
-- the seam, which is exactly what capturing a "before" reference means
if ShadowMap.forget then ShadowMap.forget() end
U.wait(20)
end
-- AB_TRACE=1 prints the state each shot was taken in. When two runs of
-- this driver disagree, this is what says which input moved.
local TRACE = os.getenv("AB_TRACE") == "1"
local function trace(name)
if not TRACE then return end
local o = game.overworld
local e = ShadowMap.extent or {}
print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d")
:format(name,
o and o.camera and o.camera.x or -1,
o and o.camera and o.camera.y or -1,
o and o.player and o.player.px or -1,
o and o.player and o.player.py or -1,
ShadowMap.res or 0,
e[1] or 0, e[2] or 0, e[3] or 0,
ShadowMap.KX or 0, ShadowMap.KZ or 0,
Voxel.angle or 0,
ChunkMesher.pending()))
end
-- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save
-- directory. When the scene differs but every input to the sun pass is
-- identical, the map is the only place left to look.
local DUMP = os.getenv("AB_SHADOWDUMP") == "1"
local function dumpShadow(name)
if not DUMP then return end
local tex = ShadowMap.texture()
if not (tex and tex.newImageData) then return end
pcall(function()
love.filesystem.createDirectory("ab_shadow")
tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png")
end)
end
local function capture(name)
trace(name)
dumpShadow(name)
local path = ("%s/%s.png"):format(ROOT, name)
game.capturePath = path
U.wait(CAPTURE_FRAMES)
local f = io.open(path, "rb")
if f then
f:close()
shots = shots + 1
else
missed = missed + 1
print("[ab] capture did not reach disk: " .. path)
end
end
local PaletteFX = require("src.render.PaletteFX")
local function setMode(mode)
local known = false
for _, m in ipairs(PaletteFX.MODES) do
if m == mode then known = true break end
end
if not known then return false end
return (pcall(PaletteFX.setMode, mode))
end
-- A fixed zoom, so the view size every shot is composed at is the same
-- one. Zoom is persisted, so without this a session that ever ran the
-- FULL preset (which fits the zoom to the window) leaves a different
-- view size behind for every later run.
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
for _, mode in ipairs(MODES) do
if setMode(mode) then
for _, when in ipairs(TIMES) do
DayNight.setting:sync(when)
for _, s in ipairs(SCENES) do
for _, rung in ipairs(RUNGS) do
U.teleport(game, s.id, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
-- pinned AFTER the voxel rung, because FULL is a preset that
-- reaches over and sets this row itself (main.lua's applyFull)
-- and persists it -- so without this, one run's FULL leaks a
-- blur level into the NEXT run's options.lua and every shot
-- differs for a reason no code change caused. Sharp by
-- default: a gaussian smears a one-pixel geometry difference
-- across the whole frame, which is exactly what a gate meant
-- to localise differences must not do. AB_TSHIFT=3 runs the
-- blur path deliberately.
Pipelines.setLevel("tiltshift", TSHIFT)
-- Wait for the build queue to drain rather than for a fixed
-- number of frames. Meshes are built on a per-frame time
-- budget, so how much of a map exists after N frames is a
-- property of the MACHINE -- a slower run captures a
-- half-built neighbour and the shot differs for no reason the
-- change caused. Draining first, then settling a fixed 40
-- frames for the camera tween, makes the scene the same
-- scene everywhere. (Safe now that the animation clock above
-- is frozen: a variable wait no longer moves the water.)
settleBuild()
capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung))
end
end
end
else
print("[ab] display mode " .. mode .. " unavailable, skipped")
end
end
print(("[ab] %d shots into %s (%d failed to reach disk)")
:format(shots, ROOT, missed))
end
+128
View File
@@ -0,0 +1,128 @@
-- Driver: WHY is the water not reflecting anything?
--
-- The reflective pass has several links and every one of them fails quietly
-- back to flat water, which looks exactly like the row being off. This walks
-- the chain in the LIVE game and prints where it stops.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec .
--
-- knobs (env):
-- REFL_MAP map id (default PALLET_TOWN)
-- REFL_SPOT "x,y[,facing]" (default 5,6,down)
-- REFL_LEVEL voxel rung (default 5, the 75-degree camera,
-- which is where a reflection is
-- most of what you can see)
-- REFL_TIME daytime pin (day/dusk/...) (default: leave as-is)
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local mapId = os.getenv("REFL_MAP") or "PALLET_TOWN"
local level = math.floor(tonumber(os.getenv("REFL_LEVEL")) or 5)
local sx, sy, facing = (os.getenv("REFL_SPOT") or "5,6,down")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "down"
local function say(...) print("[water-ssr] " .. string.format(...)) end
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
U.wait(20)
Pipelines.setLevel("voxel", level)
U.wait(40) -- outlast the camera tween and the build
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
if not V then return say("mod exports unreachable -- is it enabled?") end
local Water = V.require("Water")
local Voxel3D = V.require("Voxel3D")
local ChunkMesher = V.require("ChunkMesher")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
if os.getenv("REFL_TIME") then
DayNight.setting:sync(os.getenv("REFL_TIME"))
U.wait(5)
end
local ow = game.overworld
local map = ow and ow.map
if not map then return say("no live map") end
-- 1. is the row on at all?
say("row=%s level=%d", tostring(Water.setting:get()), Water.level())
if not Water.enabled() then
return say("STOP: WATER is OFF -- press 9, or set the row")
end
-- 2. is there any water on this map to reflect in?
local terrain, water = ChunkMesher.pair(map, false)
if not terrain then terrain, water = ChunkMesher.pair(map, true) end
say("terrain mesh=%s water mesh=%s", tostring(terrain ~= nil),
tostring(water ~= nil))
if not terrain then
return say("STOP: no terrain mesh yet -- the build is still cooking")
end
if not water then
say("STOP: this map has no water surface. That is not a fault unless")
say(" you can see a lake: check the tileset's water tiles reach")
say(" TileShape (run voxel_survey.lua for the shape breakdown).")
return
end
-- 3. did the driver give us a depth texture to read? This is the one
-- hardware requirement the rest of the mode does not already have.
say("depth canvas readable: %s", tostring(Voxel3D.depthReadable()))
if not Voxel3D.depthReadable() then
say("STOP: no readable depth canvas on this driver (tried depth24, ")
say(" depth24stencil8, depth32f and depth16).")
say(" The water falls back to the flat scene shader, which is")
say(" exactly what it looked like before this feature existed.")
return
end
-- 4. did the shader build? Both variants -- the wireframe one needs
-- derivatives, which a driver may refuse on its own.
say("shader plain=%s grid=%s",
tostring(Water.shader(false) ~= nil), tostring(Water.shader(true) ~= nil))
if not Water.shader(false) then
return say("STOP: the water shader did not compile -- the mod log has "
.. "the driver's own message")
end
-- 5. is there a sky to reflect, and something hanging in it?
local ramp, count = Sky.ramp()
say("sky ramp=%s bands=%s edge=%s", tostring(ramp ~= nil), tostring(count),
tostring(Voxel3D.skyEdge))
local body = DayNight.body()
if body then
local amt = DayNight.glow()
local w, h = Voxel3D.size()
local rpx = Sky.discRadius(h, Voxel3D.cell or 1,
{ moon = body.moon, glowAmt = amt })
local ang = rpx / ((h or 1) / math.max(1e-4, Voxel3D.fovY or 1))
say("body=%s dir=(%.2f, %.2f, %.2f) disc=%.1fpx (%.2f deg)",
body.moon and "moon" or "sun", body.dx, body.dy, body.dz, rpx,
math.deg(ang))
else
say("body: none in the sky right now (set REFL_TIME=day or =night)")
end
if not ramp then
say("NOTE: no band ramp -- indoors, or the ramp could not be built. The")
say(" sky half of the reflection is off; the ray march still runs.")
end
-- 6. how much reflection this camera is actually asking for. Both numbers
-- fall out of the rung, and between them they explain every "it only
-- works at 75" report: Fresnel decides how much shows, and the lean
-- decides whether what shows has anything in it.
local f = Water.FRESNEL_FLOOR + (Water.FRESNEL_CEIL - Water.FRESNEL_FLOOR)
* (1 - Voxel3D.descent) ^ Water.FRESNEL_POWER
say("camera: descent %.3f -> fresnel about %.2f, horizon lean %.2f",
Voxel3D.descent, f, Water.lean(Voxel3D.descent))
say("waves: %d px columns, phase %.2f", Water.WAVE_HEIGHT, Water._waveTime())
say("OK: every link is live. The effect is strongest at REFL_LEVEL=5 (the")
say(" 75-degree rung, where Fresnel is highest and the reflection is")
say(" exact) and with a low sun (REFL_TIME=dusk).")
end
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Assemble the per-map battle-arena screenshots into contact sheets.
One shot per map is a lot of files to open one at a time, so this lays them
out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which
is enough to judge a whole region at a glance and spot the one that is wrong.
python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \
.scratchpad/areas .scratchpad/sheets [order.txt]
Reads every PNG in the source directory and writes sheet_01.png,
sheet_02.png ... into the destination.
The optional third argument is a file of map ids, one per line, which both
FILTERS and ORDERS the sheets. That is what makes them worth looking at: the
authored list in data/battle_arenas.lua is grouped by region, so feeding its
keys through puts the routes together and the caves together, instead of
alphabetical order interleaving a gym with a tower floor. Without it every
PNG in the directory is used, sorted by name.
"""
import os
import sys
from PIL import Image, ImageDraw, ImageFont
PER_SHEET = 8
COLS, ROWS = 2, 4
TILE_W = 960 # each shot is 3840 wide; a quarter of that
LABEL_H = 34
PAD = 8
BG = (18, 18, 20)
LABEL_BG = (32, 32, 36)
LABEL_FG = (232, 232, 236)
def font(size):
for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def tile(path, w, h, label, f):
"""One labelled cell: the shot scaled to fit, with its map id under it."""
cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG)
try:
shot = Image.open(path).convert("RGB")
except OSError as err:
d = ImageDraw.Draw(cell)
d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f)
return cell
shot.thumbnail((w, h), Image.LANCZOS)
cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2))
d = ImageDraw.Draw(cell)
d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG)
d.text((10, h + 7), label, fill=LABEL_FG, font=f)
return cell
def wanted(src, order_path):
"""The PNGs to lay out, in the order to lay them out in."""
have = {n.lower(): n for n in os.listdir(src)
if n.lower().endswith(".png")}
if not order_path:
return sorted(have.values()), []
names, missing = [], []
with open(order_path, encoding="utf-8") as fh:
for line in fh:
key = line.strip()
if not key or key.startswith("#"):
continue
hit = have.get(key.lower() + ".png")
if hit:
names.append(hit)
else:
missing.append(key)
return names, missing
def main(src, dst, order_path=None):
names, missing = wanted(src, order_path)
if not names:
sys.exit("no PNGs to lay out from " + src)
# said out loud rather than silently skipped: a sheet set that quietly
# dropped a map would read as "every area is covered" when it is not
for key in missing:
print("MISSING no shot for %s" % key)
os.makedirs(dst, exist_ok=True)
probe = Image.open(os.path.join(src, names[0]))
tile_h = round(TILE_W * probe.height / probe.width)
f = font(24)
sheet_w = COLS * TILE_W + (COLS + 1) * PAD
sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD
made = []
for page, start in enumerate(range(0, len(names), PER_SHEET), 1):
batch = names[start:start + PER_SHEET]
sheet = Image.new("RGB", (sheet_w, sheet_h), BG)
for i, name in enumerate(batch):
cell = tile(os.path.join(src, name), TILE_W, tile_h,
os.path.splitext(name)[0].upper(), f)
col, row = i % COLS, i // COLS
x = PAD + col * (TILE_W + PAD)
y = PAD + row * (tile_h + LABEL_H + PAD)
sheet.paste(cell, (x, y))
out = os.path.join(dst, "sheet_%02d.png" % page)
sheet.save(out, optimize=True)
made.append((out, [os.path.splitext(n)[0] for n in batch]))
print("%s %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b)))
print("\n%d shots -> %d sheets" % (len(names), len(made)))
if __name__ == "__main__":
if len(sys.argv) not in (3, 4):
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2],
sys.argv[3] if len(sys.argv) == 4 else None)