Compare commits

...

19 Commits

Author SHA1 Message Date
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
27 changed files with 5735 additions and 172 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"
+436
View File
@@ -1,5 +1,441 @@
# Changelog
## 1.4.0
### 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
+33 -2
View File
@@ -9,6 +9,22 @@ 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
@@ -34,7 +50,22 @@ menu.
| `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 |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; held at SYNC (and off the menu) while VOXEL is FULL |
| `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 |
**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.
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.
+9 -2
View File
@@ -41,8 +41,15 @@ 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
["ROUTE_1"] = { x = 9, y = 16, shape = "narrow" },
-- 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" },
+131 -22
View File
@@ -9,12 +9,56 @@
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
-- hole with the world showing through, and the mon reads as a stencil.
--
-- So the paper is put back, and only where the paper was: the pic is read
-- back once, the transparent region OUTSIDE the figure is flood-filled from
-- the border, and every transparent pixel the flood could not reach -- every
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
-- untouched, so the mon still cuts cleanly against the world; only its
-- insides stop being see-through.
-- 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,
@@ -44,6 +88,21 @@ local CUT = 0.5
-- 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
@@ -52,7 +111,7 @@ local function readBack(img)
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local data = nil
local ok = pcall(function()
local canvas = love.graphics.newCanvas(w, h)
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")
@@ -72,32 +131,78 @@ local function readBack(img)
return ok and data or nil
end
-- Mark every transparent pixel reachable from the border. That set is the
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
-- 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)
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 < 0 or y < 0 or x >= w or y >= h then return end
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
local _, _, _, a = data:getPixel(x, y)
if a > CUT then return end
if not clear(x, y) then return end
outside[key] = true
top = top + 1
stack[top] = key
end
for x = 0, w - 1 do
push(x, 0)
push(x, h - 1)
for x = x0, x1 do push(x, y0) end
for y = y0, y1 do
push(x0, y)
push(x1, y)
end
for y = 0, h - 1 do
push(0, y)
push(w - 1, y)
-- 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]
@@ -124,12 +229,16 @@ function BattlePics.filled(img)
local data = readBack(img)
if not data then return end
local w, h = data:getDimensions()
local outside = markOutside(data, w, h)
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
for y = 0, h - 1 do
-- 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 = 0, w - 1 do
for x = x0, x1 do
if not outside[row + x] then
local _, _, _, a = data:getPixel(x, y)
if a <= CUT then
+69 -6
View File
@@ -141,9 +141,10 @@ local function prefetchArena(state, host)
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
ChunkMesher.setLive(live)
TerrainAtlas.setLive(live)
local terrain = ChunkMesher.request(host, false, nil, true)
or ChunkMesher.peek(host, true)
return terrain, {}
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
@@ -227,7 +228,8 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
end
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
atlasFor, cards, token, host, neighbors)
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
@@ -237,6 +239,14 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
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),
@@ -249,10 +259,15 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
-- 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
@@ -299,9 +314,36 @@ end
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)
@@ -326,7 +368,7 @@ function BattleScene.render(state, arena, textures, token)
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh = prefetchArena(state, host)
local terrain, nbMesh, water, nbWater = prefetchArena(state, host)
if not terrain then return nil end
local lx, ly, s, pw, ph = BattleScene.letterbox()
@@ -356,7 +398,7 @@ function BattleScene.render(state, arena, textures, token)
local cards = monCards(arena, groundY, textures)
Voxel3D.camera = nil
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
cards, token, host, neighbors)
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
@@ -393,6 +435,22 @@ function BattleScene.render(state, arena, textures, token)
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
@@ -473,6 +531,11 @@ function BattleScene.render(state, arena, textures, token)
-- 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
+80 -16
View File
@@ -221,8 +221,18 @@ 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
@@ -358,12 +368,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),
@@ -558,8 +570,14 @@ 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
@@ -764,18 +782,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)
@@ -858,8 +892,17 @@ 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
@@ -924,13 +967,17 @@ local function runJob(job)
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
@@ -1032,12 +1079,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
@@ -1058,6 +1107,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
+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
+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
+246 -16
View File
@@ -72,6 +72,38 @@ function OverworldBattle.enabled()
return OverworldBattle.setting:get() and true or false
end
-- ------- BACK SPRITES: the player's own mon stays on the menu
--
-- The staged shot stands BOTH mons on the map, which is the mode's whole
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
-- with its feet on the box. That silhouette is the series' shot.
--
-- So BACK SPRITES is offered as a middle setting rather than a compromise
-- imposed on everyone. With it on the foe is still geometry standing on its
-- 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.
-- 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.
--
-- OFF by default: what the mode advertises is the pair of them out there.
OverworldBattle.BACK_KEY = "battleBack"
OverworldBattle.BACK_LABEL = "BACK SPRITES"
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
OverworldBattle.BACK_LABEL,
{ false, true }, { "OFF", "ON" })
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
-- battle screen already draws exactly this.
function OverworldBattle.backPinned()
if not OverworldBattle.enabled() then return false end
return OverworldBattle.backSetting:get() and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
@@ -81,6 +113,10 @@ end
-- through the engine's own pokemon.sprite hook -- the seam that exists for
-- exactly this, so no battle code has to be touched to get it.
--
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
-- that mon is drawn in its own slot on the menu, seen from behind, and the
-- front art would be it turned round to face the player it belongs to.
--
-- Answered BEFORE a battle exists, because the battler is built before the
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
-- whether one on this map WOULD be -- the row is on, the 3D pass is
@@ -91,6 +127,7 @@ local staged = { mapId = nil, ok = false }
function OverworldBattle.wantsFront()
if not OverworldBattle.enabled() then return false end
if OverworldBattle.backPinned() then return false end
if not Voxel3D.available() then return false end
-- required here rather than through the file's own helper: this runs
-- while a battler is being built, which is before that helper is defined
@@ -140,6 +177,51 @@ OverworldBattle.HUD_RECT = {
player = { 72, 56, 88, 40 },
}
-- ------- the box at the bottom, on the same glass
--
-- The HUDs got frosted panels because black glyphs on grass are not readable.
-- The battle's text box and its menu had the opposite problem and the same
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
-- the field's own colour when the field was white and is a sheet of paper laid
-- over the bottom third of the diorama now that it is not.
--
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
-- frosted glass and laid back down translucent, with the border and the text
-- drawn over it unchanged, and the same brightness verdict flipping the ink
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
-- the engine draws inside the box is still the engine's own, in its own place.
--
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
-- READ-ONLY duplicates of that function's own branches, the same kind of
-- mirror hudLive is and for the same reason: there is no seam that reports "a
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
-- The worst a future engine change can do is frost a rectangle nothing lands
-- on, or leave a box unfrosted -- never break a battle.
--
-- Each rect stops where the next one starts rather than overlapping it: two
-- panels over the same pixels would frost it twice and leave a visible step
-- along the seam.
OverworldBattle.TEXT_RECT = {
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
-- above the box above -- its last tile row sits inside that one
moves = { 0, 64, 88, 32 },
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
mimic = { 0, 56, 128, 40 },
}
function OverworldBattle.textRects(battle)
if not battle or battle.blankForAskName then return {} end
local r = OverworldBattle.TEXT_RECT
local out = { box = r.box }
if battle.phase == "moveSelect" then
out.moves = r.moves
elseif battle.phase == "mimicSelect" then
out.mimic = r.mimic
end
return out
end
-- ------- the HUDs, out at the window's own edges
--
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
@@ -188,6 +270,17 @@ function OverworldBattle.snapRects(shot)
return rects, { enemy = ex, player = px }
end
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
-- where it always was -- but its glass is laid into the world image alongside
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
local function toWorld(rect, shot)
local s = shot.scale
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
rect[3] * s, rect[4] * s }
end
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
@@ -471,6 +564,81 @@ local function withoutBackgroundFill(battle, fn)
if not ok then error(err, 0) end
end
-- ------- the box, without its paper
--
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
-- opaque slab the frosted panel underneath is there to replace. So for the
-- length of one drawTextArea the white fills are dropped and everything else
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
-- always did, over the glass instead of over paper.
--
-- Every fill drawTextArea issues is one of those: the box's own, and the two
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
-- the border glyphs that hardware would have overwritten. Both are opaque
-- white, both are paper, and both go.
--
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
-- installed around a single call, removed on the way out including on error,
-- never live outside a battle frame this mode is drawing.
local function withoutBoxFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
if mode == "fill" then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
end
return rectangle(mode, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the hour's light, on a pic that is not geometry
--
-- Everything standing in the arena goes through the voxel shader, and that
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
-- night it goes blue, and the two mons' cards go with it 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 the engine's own
-- flat blit over the finished shot, so it arrived at noon while the world
-- behind it was at midnight -- a mon lit by nothing in the frame.
--
-- So the tint is applied by hand, to that one draw. Every colour the pics
-- layer sets is multiplied on its way past, which is the whole of it: the
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
-- slide's fade and the blink's own colour all compose with it rather than
-- being overwritten.
--
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
-- standing under a tree is darker than the tint alone, and this pic has no
-- position in the scene to be shadowed at. It carries the hour and not the
-- weather, which is the part the eye reads.
local function withTint(tint, fn, ...)
if not tint then return fn(...) end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
local gfx = love.graphics
local setColor = gfx.setColor
gfx.setColor = function(cr, cg, cb, ca, ...)
if type(cr) == "table" then
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
cr[4] }, cg, ...)
end
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
end
local ok, err = pcall(fn, ...)
gfx.setColor = setColor
-- the layer leaves whatever colour it last set, and that one is tinted;
-- hand the next caller plain white rather than a dimmed one
setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
end
-- ------- the mons, as textures for the 3D pass
--
-- The two Pokemon are not composited over the world any more: they are quads
@@ -600,11 +768,19 @@ function OverworldBattle.flashing(battle)
end
-- Both sides, or nil when neither has anything to show.
--
-- One side under BACK SPRITES: the player's mon is not standing on the map at all
-- there, it is on the menu, so it has no card to be a texture for -- and
-- nothing downstream has to know that. No billboard, and no shadow on the
-- ground under a mon that is not on it.
function OverworldBattle.textures(battle)
if not battle then return nil end
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
local okP, player = true, nil
if not OverworldBattle.backPinned() then
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
end
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
@@ -719,10 +895,44 @@ function OverworldBattle.install()
-- before this screen is composited at all, so the flat pics layer has
-- nothing left to do here. Skipped rather than left to draw underneath, or
-- every Pokemon would appear twice: once on its tile and once in its slot.
--
-- Except under BACK SPRITES, where the player's side never became geometry and this
-- layer is the only thing that draws it. The engine's own onlySide argument
-- does the whole job: one call, the player's branches alone, in the slot and
-- at the scale the GB always put them -- feet on the box, 2x, back view.
innerPics = BattleState.drawPicsLayer
function BattleState:drawPicsLayer(slide, sx, sy)
if self.dramaticShapeShot then return end
return innerPics(self, slide, sx, sy)
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
local shot = self.dramaticShapeShot
if not shot then
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
end
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
-- under the hour's own light, like everything else in the frame -- see
-- withTint, and the tint BattleScene hands over with the shot.
--
-- Except on the wavy path, where the pic is baked into the GRAYSCALE bg
-- canvas for the zone pass to colour by region. That pass keys off the
-- red channel, and a night tint pulls red down -- it would not darken
-- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a
-- second and the hour survives it fine.
local tint = not self.grayPics and shot.tint or nil
return withTint(tint, innerPics, self, slide, sx, sy, "player",
skipMenuClip)
end
end
-- The battle's text box and its menus, over the frosted glass laid down for
-- them rather than over their own white paper -- and their ink flipped with
-- the HUD's when the ground under the frame is dark, by the same rule and
-- off the same verdict.
local innerText = BattleState.drawTextArea
function BattleState:drawTextArea()
if not self.dramaticShapeShot then return innerText(self) end
local battle = self
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
withoutBoxFill(battle, innerText)
end)
end
-- Move animations are authored against the pics' fixed slots, and a single
@@ -740,10 +950,13 @@ function OverworldBattle.install()
-- mons' projected positions, less the midpoint of the slots they used to
-- sit in. A hit still lands on the mon it is aimed at.
local a = OverworldBattle.ANCHOR
local dx = (shot.enemy[1] + shot.player[1]) / 2
- (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + shot.player[2]) / 2
- (a.enemy[2] + a.player[2]) / 2
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
-- contributes no movement at all and the pair's centre has gone half as
-- far as the foe's mark did.
local px, py = shot.player[1], shot.player[2]
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
love.graphics.push()
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
local ok, err = pcall(innerAnim, self, colorized)
@@ -882,10 +1095,21 @@ function OverworldBattle.snapHUDs(battle, shot)
local live = {}
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
-- and the text box's own glass, on the same pass. It stays in the middle of
-- the frame where the engine draws it -- only the HUDs were snapped out --
-- so its GB rect is mapped into the letterbox rather than to an edge.
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
end
-- measured under the SNAPPED rects: the panels are over whatever the world
-- shows at the window's edges now, which is not what was behind them in the
-- middle of the frame
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
-- corner and black ones on the menu reads as a bug rather than as adaptation.
local dark = BattleHud.verdict(live, shot, true)
-- the box's own ink is flipped where the engine draws it, in the GB frame,
-- so the answer has to outlive this function (see drawHudPanels)
if session then session.dark = dark end
local layer = OverworldBattle.hudTexture(battle, slide, dark)
if not layer then return false end
@@ -911,23 +1135,29 @@ function OverworldBattle.snapHUDs(battle, shot)
return true
end
-- Lay the frosted glass down under whichever HUD is about to draw, and
-- record which way the glyphs have to flip.
-- Lay the frosted glass down under whichever HUD and box are about to draw,
-- and record which way the glyphs have to flip.
--
-- The fallback path only: with the HUDs snapped out to the window's edges their
-- panels went with them, and there is nothing left inside the GB frame to lay
-- glass under.
-- The panels are the fallback path only: normally the HUDs are snapped out to
-- the window's edges and their glass, and the box's, went into the world image
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
-- drawn here, in the GB frame, whichever path laid the glass under it.
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
battle.dramaticShapeDark = nil
if not shot or snapped() then return end
if not shot then return end
if snapped() then
battle.dramaticShapeDark = session and session.dark or nil
return
end
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
if not (enemy or player) then return end
local rect = OverworldBattle.HUD_RECT
local live = {}
if enemy then live.enemy = rect.enemy end
if player then live.player = rect.player end
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
if not next(live) then return end
local dark = BattleHud.verdict(live, shot)
battle.dramaticShapeDark = dark
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
+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
+30 -2
View File
@@ -130,17 +130,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
@@ -440,6 +446,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
@@ -448,6 +457,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()
+63 -11
View File
@@ -263,6 +263,27 @@ 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()
@@ -323,20 +344,51 @@ end
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
local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
{ -0.15, 0.7 }, { 0.05, 0.05 } }
-- 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 src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
local shades = PaletteFX.effectiveColors(src) or src
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
local r = math.max(Sky.DISC_MIN,
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
-- the low sun looms: the classic sunset exaggeration, and it reads
if twilight then r = r + math.max(1, math.floor(r * 0.4)) 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
@@ -429,7 +481,7 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
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 * 0.55))
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)
+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
+238 -14
View File
@@ -283,8 +283,66 @@ local activeShader = nil -- the variant this pass bound
-- 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 --
@@ -379,6 +437,41 @@ end
-- 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)
@@ -389,10 +482,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
-- 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
@@ -409,12 +507,14 @@ function Voxel3D.viewProjection(cx, cy, vw, 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) }
-- 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.
@@ -538,22 +638,29 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
end
if not sh then return false end
local name = slot or "world"
local held = slots[name]
if not (held and held.w == w and held.h == h) then
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")
if held and held.canvas and held.canvas.release then
pcall(held.canvas.release, held.canvas)
end
held = { canvas = c, w = w, h = h }
slots[name] = held
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
@@ -561,6 +668,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- 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
@@ -573,7 +688,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- 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), w / math.max(1, vw or w),
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)
@@ -720,6 +835,108 @@ function Voxel3D.flatten(color, amount)
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.
--
@@ -931,18 +1148,25 @@ 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, held in pairs(slots) do
if held.canvas and held.canvas.release then
pcall(held.canvas.release, held.canvas)
end
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
+182 -36
View File
@@ -21,6 +21,8 @@ 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 PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -404,17 +406,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 /
@@ -490,6 +500,124 @@ 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
@@ -540,7 +668,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
@@ -551,6 +679,15 @@ 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.
@@ -563,6 +700,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
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)
@@ -584,6 +726,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
mirror)))
end
end
ShadowMap.sprites(false)
ShadowMap.finish(sig)
end
@@ -593,7 +736,7 @@ 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
@@ -630,7 +773,8 @@ 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)
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
@@ -658,6 +802,34 @@ 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
@@ -689,33 +861,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- 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.
Voxel3D.seams(false)
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)
-- Authored figures, alongside the characters and with the same lean and
-- the same camera-ward pull -- they ARE characters as far as the artwork
-- is concerned, just ones the tileset draws instead of a sprite sheet.
-- Drawn after the walkers so a player standing in front of the couch
-- wins the overlap, which is 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)
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
+1303
View File
File diff suppressed because it is too large Load Diff
+144 -29
View File
@@ -80,6 +80,8 @@ 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.
@@ -281,13 +283,22 @@ applyFull = function(level)
-- the horizon flat. The curve bends the world away from a walking player,
-- which fights a fixed diorama framing
WorldCurve.setting:setIndex(1, Game)
-- and the 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 rather than forced -- the row is gone from the
-- menu while FULL is on, but a save that already had it off gets it on.
-- 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
@@ -302,24 +313,49 @@ applyFull = function(level)
end
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
-- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
-- switches it on. 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.
-- 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()
local Pipelines = require("src.render.Pipelines")
return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
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." },
.. "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 "
@@ -340,6 +376,7 @@ mod.options:define(schema)
-- 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,
@@ -354,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
@@ -369,6 +409,7 @@ local HOTKEYS = {
["5"] = VoxelGrid.setting,
["7"] = WorldCurve.setting,
["8"] = OverworldBattle.setting,
["9"] = Water.setting,
}
do
@@ -418,19 +459,19 @@ do
return
end
elseif Pipelines.canToggle("voxel", top, self.overworld) then
-- All three answer to the voxel pass's own free-roam gate --
-- 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. Two of them parameterise that
-- pass; the third (3D-BTL) decides what a battle is drawn over, and
-- 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 two keys
-- 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 three, so nothing here has to know which key it was.
-- for all of them, so nothing here has to know which key it was.
if stagedBattles() then OverworldBattle.forceOG(self) end
return
end
@@ -464,10 +505,12 @@ local function insertGrouped(out, extra)
return out
end
-- FULL owns every one of those settings, so while it is selected they are
-- taken off the menu rather than left to be changed under it -- including
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
-- longer decides anything is worse than no row.
-- 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
@@ -475,12 +518,50 @@ local function dropRow(out, id)
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
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
@@ -492,14 +573,32 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
if Voxel.isFull(Pipelines.level("voxel")) then
-- FULL keeps every mod row off the menu (the early return skips the
-- insert below), and holds DAYTIME at SYNC while the row is unreachable
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)
return dropRow(out, "pipeline:tiltshift")
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
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)
@@ -714,6 +813,16 @@ mod.content.transitions:register(BattleExit.ID, {
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
@@ -728,10 +837,16 @@ 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
@@ -745,7 +860,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.2.1"
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
+3 -2
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.2.1",
"version": "1.4.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -15,5 +15,6 @@
"engine_internals"
],
"affects_link": false,
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
"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"
}
+9 -3
View File
@@ -11,23 +11,29 @@ return {
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
"the 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",
"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",
"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 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",
+894 -10
View File
@@ -116,11 +116,16 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
local Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- ------- FULL is a preset that owns the other rows
-- ------- FULL is a preset that owns the rows describing the LOOK
--
-- While it is selected the settings it drives come OFF the menu -- including
-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
-- longer decides anything is worse than no row.
--
-- The two BATTLE rows are the exception and stay. 3D-BTL decides what a fight
-- is drawn over and BACK SPRITES how it is framed; neither is a knob on the
-- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
-- go, which is what makes it a preset rather than a lock.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
@@ -133,9 +138,14 @@ T.check(not fullIds["pipeline:tiltshift"],
"FULL takes T-SHIFT off the menu -- it owns the blur")
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
-- but the battle rows survive it: they are not knobs on the look, and FULL
-- sets them once rather than holding them, so a player who wants the classic
-- back sprite (or no staged fights at all) can still say so from inside FULL
T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
-- be reached while FULL owns it, so a value changed underneath (the mod
-- manager's page, an edited options file) snaps back when the menu asks
@@ -200,14 +210,77 @@ Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
T.eq(layoutGame.save.options.battleLayout, "wide",
"and WIDE is left alone once no battle can be staged on the map")
-- FULL owns the 3D-BTL row, so it pins the layout even with that row switched
-- off underneath it
-- and FULL does not override that. It used to: the preset owned the 3D-BTL row
-- and hid it, so "FULL is selected" was a safe stand-in for "battles are
-- staged". The row is on the menu under FULL now and can be switched off
-- there, so the stand-in would pin BATTLE LAYOUT to OG for a fight that is
-- never staged. The ROW decides, which is what every other reader of this
-- setting already believed.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"FULL pins the layout on its own, because it owns the row that would")
T.eq(layoutGame.save.options.battleLayout, "wide",
"with 3D-BTL off, FULL leaves the layout alone -- it no longer owns that row")
-- switch the row back on and the pin comes back with it, FULL or no FULL.
-- (Arriving at FULL for real runs applyFull, which switches the row on -- so
-- in the game the pin still follows the preset, by way of the row.)
Battles.setting:setIndex(1, layoutGame)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"and the row switched back on pins it again, from inside FULL")
end
-- ------- TILT and GBC FX are off the menu entirely
--
-- Two ENGINE rows, taken away for as long as 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 -- so what was left was two rows a player could set
-- and watch get reverted.
--
-- Dropped AND held at zero, which is the part that matters: 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.
do
local fxGame = {
data = Data,
save = { options = { tilt = 3, gbcfx = 2, pipelines = {}, modOptions = {} } },
mods = { modOptions = {} },
writeOptions = function() end,
}
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
Tilt.setLevel(3)
GBCFX.setLevel(2)
local fxRows = Runtime.call("ui.options.rows", function(_, r) return r end,
fxGame,
{ { id = "tilt" }, { id = "gbcfx" },
{ id = "colors" }, { id = "pipeline:voxel" } })
local fxIds = {}
for _, row in ipairs(fxRows) do fxIds[row.id] = true end
T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu")
T.check(not fxIds["gbcfx"], "and so is GBC FX")
T.check(fxIds["colors"] and fxIds["pipeline:voxel"],
"with every other row the engine offered still on it")
T.eq(fxGame.save.options.tilt, 0,
"a save that had TILT on is pinned back to off, not left on with no row")
T.eq(fxGame.save.options.gbcfx, 0, "and GBC FX with it")
T.eq(Tilt.level, 0, "the live level follows, so the frame is not still tilted")
-- and FULL, which takes its own branch through the rows hook, must not be a
-- way back in
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
local fullFx = Runtime.call("ui.options.rows", function(_, r) return r end,
fxGame, { { id = "tilt" }, { id = "gbcfx" } })
local fullFxIds = {}
for _, row in ipairs(fullFx) do fullFxIds[row.id] = true end
T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
"under FULL they are gone too -- the drop is above every branch")
Pipelines.setLevel("voxel", 2)
end
-- ------- and off FULL, the rows come back, grouped with the mode
@@ -225,7 +298,7 @@ local order = {}
for i, row in ipairs(grouped) do order[row.id] = i end
T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
"the mode's settings follow its pipeline rows")
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3,
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4,
"and sit in one unbroken block, not scattered to the end of the list")
T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
"with the engine's own later rows still after them")
@@ -306,9 +379,15 @@ end
Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#hookedRows, 5, "the options hook added a row per setting")
local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
local daytime = hookedRows[5]
T.eq(#hookedRows, 7, "the options hook added a row per setting")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
T.eq(water.label, "WATER", "the water row carries its label")
T.eq(water.value(), "FULL",
"and defaults to FULL -- reflections are the point of having the row")
water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
T.eq(water.value(), "SKY",
"stepping down drops the screen-space march and keeps the sky, sun and moon")
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
T.eq(daytime.value(), "SYNC",
"and defaults to SYNC -- no value set follows the clock on the wall")
@@ -320,6 +399,12 @@ T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label")
T.eq(battles.value(), "ON",
"overworld battles are on by default -- the mode's headline is the world "
.. "in 3D, and a battle is where the player spends half the game")
T.eq(backRow.label, "BACK SPRITES", "the back-pic row carries its label")
T.eq(backRow.value(), "OFF",
"and is off by default -- what the mode advertises is BOTH mons out on the "
.. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
"on its own key, so it persists beside 3D-BTL rather than over it")
-- stepping writes through to the one place both rows read
local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
@@ -1403,6 +1488,440 @@ T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false,
"and a frame with no height paints nothing at all")
end
-- ------- reflections on water
--
-- Water is the one surface in this mode that cannot be drawn with the rest
-- of the world: it is a mirror, and a mirror needs what it reflects to
-- already be down. So it is lifted out of the terrain mesh at BUILD time and
-- drawn as its own pass. That lift is the load-bearing part -- get it wrong
-- and a lake is either a hole in the world or is drawn twice -- and it is
-- pure geometry, so it is driven here against a hand-drawn map.
do
local Water = run.loader.exports.DRAMATIC_SHAPE.lib.require("Water")
local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
local Structures = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures")
local Shapes = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape")
local TileShapeHeights = Shapes.heights()
-- ------- the ladder
--
-- Three rungs, not a toggle: the sky half of this costs a handful of
-- instructions and the screen-space half costs a ray march, so a machine
-- that wants the sunset on the lake but not the march has somewhere to sit.
T.eq(Water.setting.values[1], "full",
"FULL is the default -- reflections are the point of having the row")
Water.setting:sync("full") -- the row test above stepped it
T.eq(Water.level(), 2, "and it reads back as the full pass")
T.eq(Water.enabled(), true, "which is on")
Water.setting:sync("sky")
T.eq(Water.level(), 1, "SKY keeps the pass but drops the screen-space march")
T.eq(Water.enabled(), true, "and is still a reflection")
Water.setting:sync("off")
T.eq(Water.level(), 0, "OFF is no pass at all")
T.eq(Water.enabled(), false,
"which is what puts the water back in the ordinary scene shader")
Water.setting:sync("full")
-- ------- the waves are geometry, not shading -- and they step at 15fps
--
-- The surface is a heightfield of one-world-pixel columns, each standing a
-- WHOLE number of pixels tall -- a voxel like every other voxel in this
-- mode -- and it advances in STEPS rather than sliding: 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.
do
local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas")
local realClock = TerrainAtlas._animFrame
local frame = 0
TerrainAtlas._animFrame = function() return frame end
local function at(f)
frame = f
return Water._waveTime()
end
local period = 60 / Water.WAVE_FPS
T.eq(period, 5, "12 steps a second is one every five engine frames")
T.eq(math.floor(period), period,
"and the beat divides the engine's 60 exactly, so every step spans the "
.. "same whole number of frames")
-- inside one step nothing moves; crossing one, it does
T.eq(at(0), at(period - 1),
"every frame inside one wave step gets the same phase -- the surface "
.. "steps rather than crawling between its own pixels")
T.neq(at(0), at(period), "and the step boundary is where it moves")
local steps = {}
for f = 0, 59 do steps[at(f)] = true end
local n = 0
for _ in pairs(steps) do n = n + 1 end
T.eq(n, Water.WAVE_FPS, "which is WAVE_FPS distinct positions in a second")
TerrainAtlas._animFrame = realClock
-- and the step is worth taking: one world pixel of the dominant train per
-- step, DERIVED from that train rather than tuned beside it, so a change of
-- wavelength moves the speed with it. A step the surface cannot resolve is
-- a smooth crawl wearing a quantised clock.
local t = Water.WAVE_TRAINS[1]
local freq = math.sqrt(t[1] * t[1] + t[2] * t[2])
local travel = (Water.waveRate() / Water.WAVE_FPS) * math.abs(t[3]) / freq
T.check(math.abs(travel - Water.WAVE_PIXELS_PER_STEP) < 1e-9,
"each step advances the dominant crest by exactly WAVE_PIXELS_PER_STEP "
.. "world pixels, so nothing ever lands half-way between two")
-- the trains reach the shader as source, off the same table the rate above
-- is derived from -- one list, so the two cannot drift
local trains = Water._trainSource()
T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS,
"every train in the table is summed by the shader")
T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil,
"at the frequency the table states")
-- the variation that keeps three periodic trains from reading as wallpaper:
-- the dominant train's amplitude breathes with the swell and its crests bow
-- with the bend, both pasted from their own tables like the trains are
T.check(trains:find(("%.4f"):format(Water.WAVE_SWELL[1]), 1, true) ~= nil
and trains:find(("%.4f"):format(Water.WAVE_BEND[1]), 1, true) ~= nil,
"the swell and the bend reach the shader off the tables that document "
.. "them, not off copies kept in step by hand")
T.check(Water.WAVE_SWELL[4] > 0 and Water.WAVE_SWELL[4] < 1,
"the swell's deepest lull thins the dominant train without deleting or "
.. "inverting it -- a sea with sets in it, not a sea that turns off")
for _, mod in ipairs({ Water.WAVE_SWELL, Water.WAVE_BEND }) do
local mf = math.sqrt(mod[1] * mod[1] + mod[2] * mod[2])
T.check(mf * 3.5 < freq,
"a modulator's wavelength sits several times the carrier's, far enough "
.. "apart that it reads as weather over the waves rather than as a "
.. "fourth wave -- which would be the soup the weights exist to avoid")
end
T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water,
"the crests stand taller than the recess TileShape sinks water into -- "
.. "they are RELIEF inside the quad's own footprint, so a bar that reaches "
.. "above the bank is clipped at the water's edge rather than spilling")
end
-- ------- the moon on the water is the moon in the sky
--
-- The reflected disc is drawn by a shader and the painted one by rectangles,
-- so nothing but shared DATA can keep them the same moon. The crater list is
-- pasted into the shader source from Sky's own table, which is the seam that
-- makes "they cannot drift" true rather than merely intended.
local craters = Water._craterSource()
local craterLines = select(2, craters:gsub("crater%(", ""))
T.eq(craterLines, #Sky.MOON_CRATERS,
"the shader gets one crater per crater the painted moon has")
for _, c in ipairs(Sky.MOON_CRATERS) do
T.check(craters:find(("%.4f"):format(c[1]), 1, true) ~= nil,
"and each one at the offset the painted moon puts it at")
end
T.check(craters:find(("%.4f"):format(Sky.CRATER_FRAC), 1, true) ~= nil,
"at the same fraction of the disc's radius")
-- and the disc is the same SIZE, which is the other half of being the same
-- moon: one function answers for the painted radius and for the angle the
-- reflection subtends it at
local px, cells = Sky.discRadius(288, 7, { moon = true })
T.eq(cells, Sky.DISC_MIN,
"a small frame floors the disc at its minimum radius in cells")
T.eq(px, Sky.DISC_MIN * 7, "reported in canvas pixels on that cell grid")
T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9 })), Sky.DISC_MIN + 1,
"and the low sun looms, exactly as the painted one does")
T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9, moon = true })),
Sky.DISC_MIN, "which is a SUNSET exaggeration -- the moon never looms")
-- the same band ramp, too: one texture, so the sky on the lake cannot be a
-- different palette from the sky over it
local rampImg, rampCount = Sky.ramp()
T.check(rampImg == nil or rampCount == #Sky.bands(),
"the reflection reads the sky off the very ramp the sky is painted from")
-- ------- the horizon lean: the reflection has to have something IN it at
-- every rung, not just the one whose horizon is in frame
--
-- The rungs are named for the camera's tilt off VERTICAL, so at 15 the eye
-- meets the water nearly head-on and the mirror ray points 75 degrees UP --
-- where the sky's bands are darkest, the sun and moon (squashed to about 6
-- degrees) are nowhere near, and a screen-space ray leaves the frame in two
-- steps. All three are correct and together they are an empty lake. The lean
-- tips the reflection toward the way the camera looks by however far that
-- camera is from having a horizon in frame.
do
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
local wasAngle, wasCam = VoxelState.angle, Voxel3D.camera
Voxel3D.camera = nil
local lean = {}
for _, deg in ipairs({ 15, 35, 50, 75 }) do
VoxelState.angle = math.rad(deg)
Voxel3D.viewProjection(256, 256, 320, 288)
lean[deg] = { Water.lean(Voxel3D.descent), Voxel3D.descent }
-- the orbit looks NORTH, so the flattened view direction is -Z and level
T.check(math.abs(Voxel3D.lookFlat[3] + 1) < 1e-6,
("the %d rung looks north along the ground plane"):format(deg))
T.eq(Voxel3D.lookFlat[2], 0,
"flattened onto it, so the lean can never tip a reflection underground")
end
-- descent is the SINE of how far below horizontal the view runs, and the
-- rungs are the camera's tilt off vertical -- so the two are complements
for _, deg in ipairs({ 15, 35, 50, 75 }) do
T.check(math.abs(lean[deg][2] - math.cos(math.rad(deg))) < 1e-6,
("the %d rung descends by cos(%d)"):format(deg, deg))
end
T.eq(lean[75][1], 0,
"at the rung whose horizon is in frame there is NO lean -- the one place "
.. "the join can be seen (the waterline, where the lake meets the painted "
.. "sky) is still the exact reflection it always was")
T.check(lean[50][1] > 0, "and it comes in as the camera tips over")
T.check(lean[35][1] >= lean[50][1] and lean[15][1] >= lean[35][1],
"growing with every rung further from the horizon")
T.eq(lean[15][1], 1,
"and complete well before the steepest rung, so every rung under the top "
.. "one aims its reflection where the top one's already lands")
-- a camera looking dead level has nothing to lean
T.eq(Water.lean(0), 0, "a level camera leans not at all")
T.eq(Water.lean(1), 1, "and one looking straight down leans all the way")
T.eq(Water.lean(Water.LEAN_FROM), 0,
"the ramp starts exactly where the top rung sits, so that rung is the one "
.. "the lean never touches")
T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12,
"and the elevation it aims at IS that rung's own, stated as the same "
.. "number rather than beside it")
VoxelState.angle, Voxel3D.camera = wasAngle, wasCam
end
-- ------- people do not shadow water
--
-- The sun pass is ONE map, so a surface cannot ask what threw a shadow
-- unless the map says -- and it does, in the blue channel, which was zero
-- anyway. Water is the only surface that asks: a character standing at a
-- lake's edge laid a hard cut-out of its own sprite across a surface already
-- showing the sky and the shoreline, which reads as a sticker rather than as
-- a shadow. Everything the world casts still shades it.
do
local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
T.check(type(ShadowMap.sprites) == "function",
"the sun pass can be told it is drawing the cast rather than the world")
-- inert outside a pass, like every other toggle on it -- a caller that
-- brackets a draw it never made must not send to a shader that is not bound
T.check(pcall(ShadowMap.sprites, true) and pcall(ShadowMap.sprites, false),
"and saying so outside one is harmless")
local shadowSrc = ShadowMap._source and ShadowMap._source() or nil
if shadowSrc then
T.check(shadowSrc:find("fract(d), sprite", 1, true) ~= nil,
"the marker rides the channel the depth pack left free, so it costs "
.. "nothing: the map is still two channels of depth")
end
end
-- ------- the compiled variants
local plain = Water._source(false)
local gridded = Water._source(true)
T.check(plain:find("#define WAVE_STEPS " .. Water.WAVE_STEPS, 1, true) ~= nil,
"the relief march's step count is compiled in too")
-- the whole surface is answered per COLUMN: the ray picks one, and the art,
-- the shading, the reflection and the dither all read that one rather than
-- the fragment's own place on the flat quad. A smoothly-shaded reflection
-- over hard-edged 8-bit water is two pictures stacked.
T.check(plain:find("floor(waveRaw(q) * waveHeight + 0.5)", 1, true) ~= nil,
"column heights are floored to WHOLE world pixels -- a fractional step is "
.. "a smooth wave with extra arithmetic, not a bar")
-- and the normal is read off the SMOOTH field underneath, which is the
-- difference between a moon on the water and confetti: integer heights give
-- integer differences, so a normal built from them can only point in about
-- five directions and a two-degree disc falls between them
T.check(plain:find("float h = waveRaw(q);", 1, true) ~= nil,
"but the reflection's normal comes off the smooth surface the columns are "
.. "a quantisation of, so the ray sweeps instead of jumping")
T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil
and plain:find("waveNormal(col,", 1, true) ~= nil,
"still one answer per column, so the surface stays pixel-quantised in "
.. "space while the value it reflects with is continuous")
T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil,
"and the visible column is found by walking the view ray through the "
.. "slab, which is what makes a tall bar hide the short ones behind it")
-- the march's reach grows as one over the ray's descent, so a grazing camera
-- asks for hundreds of world pixels of it from a fixed number of samples --
-- which stepped over whole crests and smeared the surface into streaks
-- a sample is worth a SCREEN pixel of surface, so that is the stride: held
-- at a world pixel up close (finer buys nothing and skipping costs the
-- pepper) and opened out with distance (holding it there just runs the march
-- out of samples part-way down the slab, which flattened the lowest rung's
-- whole middle distance)
T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil
and plain:find("max(WAVE_STRIDE, dist * pxAngle / dy)", 1, true) ~= nil,
"the relief stride is a screen pixel's worth of surface, floored at a "
.. "world pixel")
T.check(Water.WAVE_STRIDE <= 1,
"and that floor is at most ONE world pixel, because a column is one world "
.. "pixel wide -- a longer one steps over columns, and which ones it "
.. "misses changes fragment to fragment, which is the peppery noise")
-- and the art is read off the COLUMN rather than by offsetting the
-- fragment's own uv by however far the march happened to travel: one world
-- pixel is one texel, so a column's texel follows from where it stands and
-- two fragments landing on the same column cannot disagree about it
T.check(plain:find("org + (mod(col, 8.0) + 0.5) * texel", 1, true) ~= nil,
"a column's art follows from its own world position, so it cannot swim "
.. "with the camera or speckle between neighbouring fragments")
T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil,
"and the column is what is handed to it")
-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on
T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil,
"with V-GRID on, the seams outline the column the ray landed on -- every "
.. "voxel of water its own block -- rather than ruling a grid across the "
.. "flat quad underneath and ignoring the bars entirely")
T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil,
"measured off the smooth plane, because the hit jumps a whole column "
.. "between neighbouring fragments and its own derivative is a step")
T.check(plain:find("march(surf, r)", 1, true) ~= nil,
"the reflection marches from that column, not from the raw fragment")
T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil,
"and the dither's checkerboard is cut from the columns too, so a camera "
.. "pan slides the world through nothing")
T.check(plain:find("#define RAY_STEPS " .. Water.RAY_STEPS, 1, true) ~= nil,
"the march's step count is compiled in -- GLSL wants a constant bound")
T.check(plain:find("VOXEL_GRID", 1, true) ~= nil,
"the wireframe is guarded in the source")
T.check(plain:find("#define VOXEL_GRID", 1, true) == nil,
"and off in the plain variant")
T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil,
"so a frame with the seams on gets its own compilation, like the scene "
.. "shader -- a driver that refuses derivatives loses the seams and not "
.. "the water")
T.check(plain:find("//@CRATERS", 1, true) == nil,
"and the crater placeholder is gone by the time a driver sees the source")
-- ANDROID. GLSL ES defaults fragment floats to mediump and samplers to
-- lowp, and this shader is the one place in the mod where both defaults
-- are fatal: world coordinates run past fp16's fraction, the depth read
-- rounds to steps the march falls straight through, and -- the sharp edge
-- -- `vp` is declared by BOTH stages, whose defaults disagree, which GLSL
-- ES answers by refusing to LINK the shader at all. Flat lakes, empty log.
-- The sky's band ramp is this same lesson learned once already.
T.check(plain:find("precision highp float;", 1, true) ~= nil,
"the pixel stage lifts GLSL ES's mediump default to highp, so the march "
.. "keeps its fraction and the dual-declared vp links at one precision")
T.check(plain:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil,
"guarded, so the odd GPU without fragment highp still compiles and "
.. "falls back flat instead of failing loudly")
T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil,
"the world-position varying is qualified like the scene shader's vGrid "
.. "rather than left to the fragment default")
T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil,
"and the depth sampler is lifted off lowp, which is eight bits of depth")
T.check(plain:find(
"effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc)",
1, true) ~= nil,
"effect()'s own floats stay pinned to LOVE's prototype precision -- the "
.. "Xclipse compiler reads a definition that drifted from the forward "
.. "declaration as an illegal overload and refuses the whole shader")
T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil,
"the depth test normalises the pixel coord by the canvas's own pixel "
.. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two "
.. "differ by the density, which clamped the lookup and cut the water "
.. "into blocks")
-- ------- the lift itself
--
-- A pond in a field: four water cells recessed below flat ground. The
-- shipped maps are the real thing but a picture states the invariant
-- exactly, and this one needs no atlas, no GPU and no fixture.
local WATER_TILE, GRASS_TILE = 20, 3
local pond = {
{ GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE },
{ GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE },
{ GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE },
{ GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE },
}
local pondMap = {
id = "DS_TEST_POND",
tileset = { id = "DS_TEST_SET", image = "gfx/tilesets/ds_test.png",
tilesPerRow = 16, imageWidth = 128, imageHeight = 48,
blocks = {}, grassTile = -1 },
def = { width = 1, height = 1, tileset = "DS_TEST_SET" },
walkable = { [GRASS_TILE] = true },
waterTiles = { [WATER_TILE] = true },
doorTiles = {},
tileAt = function(_, tx, ty)
return pond[(ty % 4) + 1][(tx % 4) + 1]
end,
cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end,
isWaterCell = function(self, cx, cy)
return self:cellTile(cx, cy) == WATER_TILE
end,
isWalkableCell = function(self, cx, cy)
return self:cellTile(cx, cy) == GRASS_TILE
end,
inBounds = function(_, cx, cy)
return cx >= 0 and cy >= 0 and cx < 2 and cy < 2
end,
}
-- body-only, so the border ring is out of it and the count is the picture
local _, _, whole = ChunkMesher.geometry(pondMap, true, nil)
Structures.invalidate(pondMap.id)
local landVerts, _, land, waterVerts, _, wet =
ChunkMesher.geometry(pondMap, true, nil, true)
T.check(wet > 0, "the pond's surface comes out as water quads")
T.eq(land + wet, whole,
"and the split is a MOVE, not a copy: every quad the one-sink build "
.. "emitted is in exactly one of the two")
T.eq(#waterVerts, wet * 4, "the water sink holds whole quads")
-- every water vertex sits on the recessed plane, which is what says the
-- surface and only the surface was lifted -- the shoreline faces that drop
-- from the ground down to it belong to the GROUND that exposes them, and
-- must stay in the terrain mesh or a lake is ringed by a slit into the sky
local heights = Shapes.heights()
for _, v in ipairs(waterVerts) do
T.check(v[2] == heights.water,
"a water vertex stands on the water plane, not on a shoreline face")
end
local shore = 0
for _, v in ipairs(landVerts) do
if v[2] < 0 then shore = shore + 1 end
end
T.check(shore > 0,
"and the shoreline bands below ground level stayed with the terrain")
-- a map with no water at all splits into everything and nothing, rather
-- than into an empty terrain mesh
Structures.invalidate(pondMap.id)
local dry = {}
for y = 1, 4 do
dry[y] = {}
for x = 1, 4 do dry[y][x] = GRASS_TILE end
end
pond = dry
local _, _, dryLand, _, _, dryWet = ChunkMesher.geometry(pondMap, true, nil,
true)
T.check(dryLand > 0, "a map with no water still meshes its ground")
T.eq(dryWet, 0, "and hands back no water surface at all")
-- ------- and the pairing
--
-- The terrain mesh and the water lifted out of it are ONE answer: they came
-- from the same build, so a caller must never end up holding a full mesh
-- beside a body build's water (the ring's ponds twice, the body's as holes).
-- pair() is the only way to ask, which is what makes that unpairable.
local mesh, wetMesh = ChunkMesher.pair({ id = "DS_NOT_A_MAP" }, false)
T.eq(mesh, nil, "an unbuilt map pairs to nothing")
T.eq(wetMesh, nil, "on both halves, so a caller cannot half-draw one")
Structures.invalidate(pondMap.id)
ChunkMesher.invalidate(pondMap.id)
Shapes.invalidate()
end
Voxel.angle = 0
-- ------- overworld battles: where the fight is staged
@@ -1868,6 +2387,371 @@ T.eq(e[1], 0, "the bands are full width")
T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
end
-- ------- and the box at the bottom is on the same glass
--
-- The HUDs got frosted panels because black glyphs on grass are not readable.
-- The text box had the opposite problem and the same cause: an opaque white
-- slab over the bottom third of the diorama, which was the field's own colour
-- back when the field was white. The rects here are what the glass is cut to,
-- and they are a READ-ONLY mirror of drawTextArea's own branches -- so this is
-- where a future engine that moves a box says so.
do
local rects = Battles.textRects({ phase = "messages" })
T.check(rects.box ~= nil, "there is always a box: drawTextArea opens with one")
T.eq(rects.box[2] + rects.box[4], 144,
"and it reaches the bottom of the frame")
T.eq(rects.box[3], 160, "full width, like Font.drawBox(0, 12, 20, 6)")
T.eq(rects.box[2], 96, "starting on the row the player's mon stands on")
-- the menu the player picks FIGHT on is that same box, so nothing is added
T.eq(Battles.textRects({ phase = "menu" }).moves, nil,
"the battle menu draws inside the box already there")
-- the two phases that put a SECOND box above it get a second panel, trimmed
-- to the rows above the first: two panels over the same pixels would frost it
-- twice and leave a step along the seam
for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do
local more = Battles.textRects({ phase = phase })
local extra = more.moves or more.mimic
T.check(extra ~= nil, phase .. " raises a box of its own, and it is frosted")
T.eq(extra[2] + extra[4], more.box[2],
"which stops exactly where the box below it starts, so they never overlap")
T.check(extra[1] >= 0 and extra[1] + extra[3] <= 160 and extra[2] >= 0,
"and stays inside the frame the battle is drawn in")
end
-- AskName blanks the field on purpose -- the nickname prompt is meant to sit
-- on nothing -- so there is no box and no glass under one
T.eq(next(Battles.textRects({ phase = "menu", blankForAskName = true })), nil,
"the nickname prompt's blank field gets no glass")
T.eq(next(Battles.textRects(nil)), nil, "and no battle, no boxes")
end
-- ------- BACK: the player's own mon stays on the menu
--
-- The staged shot stands both mons on the map, which costs the framing Gen 1
-- is most recognisable by: your own Pokemon seen from behind, sitting on the
-- battle menu. BACK SPRITES hands that back without giving up the fight on the map --
-- the foe is still geometry on its own tile.
do
T.eq(Battles.backSetting:get(), false,
"BACK SPRITES is off by default: both mons out on the map is what the mode is")
T.eq(Battles.backPinned(), false, "so nothing is pinned to the menu")
local backGame = { save = { options = { modOptions = {} } },
mods = { modOptions = {} } }
Battles.setting:setIndex(1, backGame) -- 3D-BTL on
Battles.backSetting:setIndex(2, backGame) -- BACK SPRITES on
T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned")
T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true,
"and it persists on its own key, beside 3D-BTL rather than over it")
T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true,
"which is still where it always was")
-- and it means nothing at all with staged battles off: there is no staged
-- shot for a back pic to be pinned in front of, and the engine's own battle
-- screen already draws exactly this
Battles.setting:setIndex(2, backGame)
T.eq(Battles.backPinned(), false,
"with 3D-BTL off the setting decides nothing, whatever it is left at")
T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
-- ...so the row comes off the menu with it, on the same reasoning the mod's
-- other absent rows come off: a row that no longer decides anything is worse
-- than no row
local offRows = Runtime.call("ui.options.rows", function(_, r) return r end,
backGame, { { id = "tilt" } })
local offIds = {}
for _, row in ipairs(offRows) do offIds[row.id] = true end
T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
T.check(not offIds["DRAMATIC_SHAPE:battleBack"],
"but BACK SPRITES is off the menu while there is no staged fight to be about")
Battles.setting:setIndex(1, backGame)
local onRows = Runtime.call("ui.options.rows", function(_, r) return r end,
backGame, { { id = "tilt" } })
local onAt = {}
for i, row in ipairs(onRows) do onAt[row.id] = i end
T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
"directly under the row it belongs to")
Battles.backSetting:setIndex(1, backGame) -- and off for the rows below
end
-- ------- the hour reaches the FLAT world too
--
-- The clock reaches the diorama through the voxel shader's tint uniform, which
-- the 2D tile path never runs. With the mode off the same evening left the flat
-- world at permanent noon.
--
-- The fix is one multiplied rectangle, and the whole difficulty is WHERE. Not
-- on the world canvas -- in a colorized mode that is grayscale art the palette
-- shader classifies by RED CHANNEL, so tinting first would move every pixel
-- into the wrong shade bucket rather than darkening it. Not over the finished
-- frame either, or the dialog boxes darken with the world they are held up in
-- front of. Between the two, which is the one instant with no engine seam in
-- it -- worldPresent only runs when a pipeline drew the world, which in flat
-- mode is exactly what did not happen.
--
-- So the boundary is found by identity: `blit` passes the canvas it is
-- compositing as the first argument, so the first draw of the renderer's UI
-- canvas IS the moment the world is finished and the paper has not started.
-- That is what this drives -- the gates, and the ordering.
do
local DayTint = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayTint")
local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
-- the map the hour is asked about is the one the player is standing on, read
-- off the live game rather than passed in -- so there has to be one
local Game = require("src.core.Game")
local owWas = Game.overworld
Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
-- ------- the gates
--
-- A frame with a pipeline's world image in it was tinted inside that
-- pipeline's own shader; painting again would apply the hour twice.
DayNight.setting:sync("night")
T.check(DayTint.forFrame({ worldActive = true, worldOverride = {} }) == nil,
"a frame a render pipeline drew is left alone -- it tinted itself")
T.check(DayTint.forFrame({ worldActive = false }) == nil,
"and so is a frame with no world in it at all, like a menu over nothing")
T.check(DayTint.forFrame(nil) == nil, "and no renderer, no tint")
-- midday is a multiply by white, so it is skipped rather than drawn: a game
-- with the clock at DAY issues not one extra call
DayNight.setting:sync("day")
T.check(DayTint.forFrame({ worldActive = true }) == nil,
"at midday the tint is white, so nothing is painted")
-- and 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
DayNight.setting:sync("night")
T.check(DayTint.forFrame({ worldActive = true }) ~= nil,
"at night, outdoors, there is a tint to paint")
Game.overworld = { map = { id = "OAKS_LAB", def = { tileset = "HOUSE" } } }
T.check(DayTint.forFrame({ worldActive = true }) == nil,
"but indoors the hour does not reach the floor")
Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
-- ------- and the ordering, driven through the real wrap
--
-- A stand-in renderer whose endFrame issues the two draws the real one does,
-- in the real order: the world canvas, then the UI canvas.
DayNight.setting:sync("night")
local Renderer = require("src.render.Renderer")
local realEnd, realHook = Renderer.endFrame, Renderer.dramaticShapeTintHook
local log = {}
local uiCanvas, worldPixels = { "the UI canvas" }, { "the world canvas" }
Renderer.dramaticShapeTintHook = nil
Renderer.endFrame = function(self)
log[#log + 1] = "world"
love.graphics.draw(worldPixels, 0, 0)
log[#log + 1] = "ui"
love.graphics.draw(self.canvas, 0, 0)
love.graphics.draw(self.canvas, 0, 0) -- a second SGB zone's quad
end
DayTint.install()
local realRect = love.graphics.rectangle
love.graphics.rectangle = function(...)
log[#log + 1] = "tint"
return realRect(...)
end
Renderer.endFrame({ canvas = uiCanvas, worldActive = true, map = true })
love.graphics.rectangle = realRect
T.eq(table.concat(log, ","), "world,ui,tint",
"the tint lands after the world is composited and before the UI blit draws")
local painted = 0
for _, step in ipairs(log) do if step == "tint" then painted = painted + 1 end end
T.eq(painted, 1,
"once, not once per SGB zone quad the UI blit issues")
-- a frame the gates decline must not leave the shim installed on love.graphics
local drawWas = love.graphics.draw
Renderer.endFrame({ canvas = uiCanvas, worldActive = true, worldOverride = {} })
T.eq(love.graphics.draw, drawWas,
"a declined frame does not leave a wrapper on love.graphics.draw")
Renderer.endFrame, Renderer.dramaticShapeTintHook = realEnd, realHook
Game.overworld = owWas
DayNight.setting:sync("sync")
end
-- ------- and a pinned back pic is not a stencil
--
-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
-- decoded PNGs key that shade to alpha 0 -- free on a white field, a hole with
-- the world showing through over a route. BattlePics puts the paper back.
--
-- It does that by flooding the background INWARD and filling whatever the
-- background cannot reach. Started at the image border that finds nothing at
-- all -- a Gen 1 figure is an open drawing, and its belly walks out between
-- two legs and off the bottom of the frame -- so every mon was a stencil.
--
-- So the flood starts at the edges of the ARTWORK'S OWN 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 the margin all at the top, so a mon's lowest
-- row is the last row it was given. Seed that cut and the background pours up
-- inside the figure, which was the channel of world showing through the middle
-- of a Clefairy.
--
-- Both halves are driven here, because getting one right at the other's
-- expense is exactly what went wrong twice: an earlier rule that filled
-- anything with ink to its left, right and above closed the channel and then
-- filled the notch between a Rattata's ears and the gap between its body and
-- its tail, which are background and have the drawing over them.
do
local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
-- Run one hand-drawn figure through the real BattlePics and hand back a
-- reader over what came out. The pic is faked at the readback seam, which is
-- the only thing between this and the pixels the engine would have blitted.
local lastCanvas = nil -- what the readback asked newCanvas for
local function fill(rows)
local W, H = #rows[1], #rows
local built = nil
local function fakeData()
local px = {}
for y = 0, H - 1 do
for x = 0, W - 1 do
px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
end
end
return {
px = px,
getDimensions = function() return W, H end,
getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end,
setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
}
end
local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
love.graphics.newCanvas = function(cw, ch, opts)
lastCanvas = { w = cw, h = ch, opts = opts }
return { setFilter = function() end, release = function() end,
newImageData = fakeData }
end
love.graphics.newImage = function(data)
built = data
return { setFilter = function() end }
end
local pic = { getDimensions = function() return W, H end }
local out = BattlePics.filled(pic)
love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
-- deliberately NOT invalidated: each figure brings its own pic, and the
-- cache check at the bottom needs one of them still in there
return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end
end
-- ------- the cut at the feet, which is what the closed bottom edge is for
local out, pic, opaque = fill({
"..#..#..", -- two ears, with sky between them
"..#..#..",
"..####..", -- and the head closing under them
".#....#.", -- belly: nothing under it but the edge the drawing stops at
".#....#.",
".#....#.",
".#.##.#.", -- legs, with the gap between them running down to that edge
".#.##.#.",
})
T.check(out ~= pic and opaque, "the pic comes back rebuilt: there was paper to put back")
T.check(opaque(2, 3) and opaque(3, 4) and opaque(5, 5),
"the belly is filled edge to edge -- no channel of world down the middle")
T.check(opaque(2, 7),
"and so is the notch between its legs, which the same cut runs through")
-- the sky between two ears reaches the top of the box, so it is background
T.check(not opaque(3, 0) and not opaque(4, 1),
"the sky between its ears stays sky")
-- and everything outside the artwork's own box is never touched, which is what
-- keeps the silhouette cutting cleanly instead of standing in a white rectangle
T.check(not opaque(0, 0) and not opaque(7, 0), "the corners stay transparent")
T.check(not opaque(0, 4) and not opaque(7, 4), "and the columns beside it")
-- ------- and a pocket that drains out to the SIDE is background, however
-- much of the drawing is over it
--
-- This is the regression the ray rule caused: ink to the left, ink to the
-- right, ink above, and still plainly the gap between a body and a tail.
-- Every transparent pixel in this one drains out through the notch at (2,3)
-- and away to the left, so NONE of it is paper -- and a pic with no paper to
-- put back is handed straight back, unrebuilt. That identity IS the assertion:
-- under the ray rule this figure came back rebuilt with the pocket filled in.
local gapOut, gapPic = fill({
"..#####.", -- a brow, with the drawing over the pocket
"..#...#.",
"..#...#.",
"....###.", -- which opens at the left, and drains out that way
"..#####.",
"..#####.",
})
T.eq(gapOut, gapPic,
"a pocket the background can walk into from the side is not paper")
-- ------- and neither is a wide MOUTH along the bottom
--
-- Two things meet the underside of a figure. A DRAIN is where the drawing ran
-- out -- a belly leaking 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 the drains
-- run 3-4 pixels and the mouths 10-17, so BattlePics.DRAIN sits at 6.
--
-- This figure's stride is eight wide, so nothing in it is paper and it comes
-- back unrebuilt -- the identity again.
local strideOut, stridePic = fill({
"..##############..",
"..##############..",
"..##############..",
"..###........###..", -- a stride eight wide, past the drain cut
"..###........###..",
"..###........###..",
})
T.eq(strideOut, stridePic,
"the world shows through the gap between a trainer's legs")
-- the same figure with a two-pixel gap IS a drain, and fills
local drainOut, drainPic, drain = fill({
"..##############..",
"..##############..",
"..##############..",
"..######..######..", -- a belly running out, not a stride
"..######..######..",
"..######..######..",
})
T.check(drainOut ~= drainPic and drain and drain(8, 4),
"a narrow one is where the drawing ran out, and is paper")
-- ------- and the readback is measured in PIXELS, which is what kept the mons
-- the size of the squares they stand on
--
-- love.graphics.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
-- density is routinely 2.75. So an untold newCanvas(56, 56) allocated a
-- 154x154 texture on a phone, the pic was magnified into it, and newImageData
-- read the magnified copy back at its own size -- an image 2.75x the artwork,
-- which drawPicsLayer then drew at 1:1 because it trusts getWidth(). The mon
-- stood on the map three times the size of its tile.
--
-- Only for a pic with paper to put back, which is why it read as a bug in
-- particular Pokemon (a giant Pidgey beside a normal mon) rather than as a
-- scale that was wrong everywhere.
T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1,
"the readback canvas is one texel per pic pixel, on a highdpi phone too")
T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels")
-- the answer is cached on the image, so a pic costs one readback a session
-- rather than one a frame -- checked on the first figure, which is still in
-- there because fill() does not clear it
T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original")
BattlePics.invalidate()
end
-- ------- the way out of a battle is a fade, not a cut
--
-- The engine wipes INTO a battle and cuts straight out of it. While voxel mode
+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
+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
+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
+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