Compare commits

..

8 Commits

Author SHA1 Message Date
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 731ecd9677 Merge pull request #34 from DramaticShape/mobile-dpi-fix
Mobile dpi fix
2026-07-31 14:10:24 -04:00
17 changed files with 3131 additions and 97 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"
+223
View File
@@ -1,5 +1,228 @@
# 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
+17
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,6 +50,7 @@ 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 |
| `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 |
+64 -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
+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
+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)
+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
+18 -6
View File
@@ -81,6 +81,7 @@ 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.
@@ -282,6 +283,10 @@ 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)
@@ -332,6 +337,11 @@ 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,
@@ -366,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,
@@ -398,6 +409,7 @@ local HOTKEYS = {
["5"] = VoxelGrid.setting,
["7"] = WorldCurve.setting,
["8"] = OverworldBattle.setting,
["9"] = Water.setting,
}
do
@@ -447,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
@@ -848,7 +860,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.3.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.3.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"
}
+4
View File
@@ -21,6 +21,7 @@ return {
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"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",
@@ -30,6 +31,9 @@ return {
},
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",
+444 -4
View File
@@ -298,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")
@@ -379,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, 6, "the options hook added a row per setting")
local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
local backRow, daytime = hookedRows[5], hookedRows[6]
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")
@@ -1482,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
+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
+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