mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:40:51 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d37cd8e6 | |||
| 3f7210bfcd | |||
| 9ef8644bff | |||
| e6d4059c38 | |||
| 1a283d6771 | |||
| a7c9541ac4 | |||
| 91cc2d6f51 | |||
| c82598b24c | |||
| 47363b8d23 | |||
| 752653e243 | |||
| 6887f5d951 | |||
| a140980b1d | |||
| eb231d221e | |||
| 980383bb92 | |||
| 22b58e27a4 | |||
| 98f7419b72 | |||
| 92fef2a37e | |||
| 8f38aeb36e | |||
| 9a9441899a | |||
| 7f76caa5f6 | |||
| be2f0464c5 | |||
| 8728783b22 | |||
| 731ecd9677 | |||
| 851f36d46f | |||
| 775757b2d6 | |||
| 20f1807edd | |||
| 4da8e5dc3e | |||
| 3eb62a5e00 | |||
| 26d1d96d52 | |||
| d9a000d7ad | |||
| f12b564dcd | |||
| d1a1c69c7d | |||
| 500556c3fc | |||
| e621c28a74 | |||
| eabc8af716 | |||
| 785838e6cd | |||
| 14f844ad8e | |||
| cb325af6cf | |||
| 50f8f6bfe3 | |||
| 284172db7b | |||
| 6d27d91411 | |||
| e898cedad6 | |||
| f2a18b62b2 | |||
| 5ff56eab46 | |||
| 99e18f7b5b | |||
| 9c263f0937 | |||
| 7e3245a9ef | |||
| 18857e039a | |||
| f841378330 | |||
| 6d7a93dbad | |||
| e14953c78a | |||
| 8dc046f72e | |||
| c3ba10e17a | |||
| 41a86b5fb2 | |||
| 9a34ba9c77 | |||
| 097bdcd14b | |||
| e838ff4558 | |||
| 8e7a80055d | |||
| 5ea70a278f | |||
| 60d5362c89 | |||
| 3c1781f6b1 | |||
| b21fd46ea7 | |||
| 0f303e7975 |
@@ -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"
|
||||
+1112
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -16,11 +32,26 @@ clear ground — shot over the shoulder, the player's mon low and left and
|
||||
the enemy high and right, with a slow parallax drift behind them and a
|
||||
depth-of-field pass that keeps both of them sharp.
|
||||
|
||||
Purely presentational. Nothing here reaches collision, movement, triggers
|
||||
or scripts — it changes what the world *looks* like and nothing about what
|
||||
it *is*. The battle arena is where the **camera** goes, not where anybody
|
||||
goes: no cell, facing, flag or warp is written, so the player is standing
|
||||
exactly where the fight found them when it ends.
|
||||
And the whole thing from inside. The ladder's top rung, **1ST**, dives the
|
||||
camera into the player's own head: free look on the mouse (captured while
|
||||
the rung is on — left click is A, right click is B), the right stick, or a
|
||||
touch dragged across open screen; free movement that goes where you look,
|
||||
at any angle, sliding along walls — the left stick's raw deflection, the
|
||||
touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn
|
||||
to face the eye wearing the frame their pose shows *this* viewer — walk
|
||||
behind someone and you see their back — and the sky, the shadows and the
|
||||
water reflections all carry over, because the head rides the same placed
|
||||
camera the battle shot proved out.
|
||||
|
||||
Presentational, with one deliberate exception. Every rung but 1ST changes
|
||||
what the world *looks* like and nothing about what it *is*; the battle
|
||||
arena is where the **camera** goes, not where anybody goes. 1ST replaces
|
||||
the grid walk with a free one while it is selected — but even there the
|
||||
game is untouched: the walk asks the engine's own collision the same
|
||||
questions a grid step asks, keeps the player's cell synced, and runs the
|
||||
engine's own landing pipeline per cell crossed, so warps, encounters,
|
||||
ledges, gates and scripts all fire exactly as themselves. Step off the
|
||||
rung and the grid walk is back.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -34,43 +65,23 @@ 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 **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
|
||||
|
||||
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
||||
on the world whether or not the free-roam camera is pitched over.
|
||||
|
||||
## Where a battle is staged
|
||||
Two of the engine's own rows are taken away while this mod is installed:
|
||||
**TILT**, which is the flat fake of what this mode does for real, and **GBC
|
||||
FX**, a full-screen present pass over the top of the diorama. Both are held at
|
||||
off rather than merely hidden — a row that is not there cannot switch off a
|
||||
value an older save arrived with. Uninstall and both come back, at whatever
|
||||
they were last set to.
|
||||
|
||||
The mod looks for the nearest clearing shaped like this, where every `x` is
|
||||
a cell the player could walk onto — the two mons three cells apart down the
|
||||
middle, with a one-cell apron so the camera looks across floor rather than
|
||||
into a wall:
|
||||
|
||||
```
|
||||
x x x
|
||||
x O x O the enemy's mon
|
||||
x x x
|
||||
x x x
|
||||
x P x P your mon
|
||||
x x x
|
||||
```
|
||||
|
||||
Indoors, in a corridor or in a cave there is often no room for that, so the
|
||||
search relaxes to the same three-cell gap with the apron given up:
|
||||
|
||||
```
|
||||
O
|
||||
x
|
||||
x
|
||||
P
|
||||
```
|
||||
|
||||
If neither will fit — and on a map with no open ground at all, or on a
|
||||
machine with no depth-buffer support — the battle draws exactly the way it
|
||||
always did. Water counts as open ground while you are surfing and not
|
||||
otherwise, and warp tiles never count, so a fight is never framed in a
|
||||
doorway.
|
||||
|
||||
Two of those keys are taken off the engine: `3` was **TILT** and `5` was
|
||||
**GBC FX**. Neither is reachable by key while this mod is enabled, and both
|
||||
are still on the OPTIONS menu. Pressing `3` also switches both off — they
|
||||
fight the diorama, and it is the way back from having left one on.
|
||||
Everything the battle screen draws as a box — the two HUD blocks, the text
|
||||
box and the menus over it — sits on frosted glass rather than on the white
|
||||
field it used to have behind it: the world underneath, blurred and laid back
|
||||
down translucent, with the ink flipping white where the ground it lands on is
|
||||
dark. Nothing the engine draws inside a box moves; only the paper is gone.
|
||||
+11
-1
@@ -39,7 +39,17 @@
|
||||
|
||||
return {
|
||||
-- ------- routes
|
||||
["ROUTE_1"] = { x = 0, y = 16, shape = "wide" },
|
||||
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
|
||||
-- wide shape only fits in the western connection border, which staged every
|
||||
-- fight at the edge of the world instead of on the road.
|
||||
--
|
||||
-- Of the seventeen spots the route has outside that border, fourteen are
|
||||
-- this one mid-route clearing and the other three bury the near mon behind
|
||||
-- a hedge -- which the clearance test passes, since it measures terrain
|
||||
-- height along the sightline and a hedge in the apron row is not terrain.
|
||||
-- So the choice is where in the clearing, and this is its west end: tree
|
||||
-- line square behind the pair, nothing crossing either of them.
|
||||
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
|
||||
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
|
||||
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
||||
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
|
||||
|
||||
+1739
-245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
-- Voxel world mode: anti-aliasing, by supersampling.
|
||||
--
|
||||
-- Everything else in this mod is flat art blitted at whole pixels; this one
|
||||
-- pass is real geometry seen through a perspective camera, and a polygon
|
||||
-- edge that lands at an angle across the pixel grid is the one place in the
|
||||
-- game where a hard stair-step is not a stylistic choice. A roof ridge, a
|
||||
-- ledge lip, a tree's silhouette against the sky and the leaning card of a
|
||||
-- character are all cut by an edge that has no reason to line up with
|
||||
-- anything, and at the shallow rungs -- where the diorama reads most like a
|
||||
-- photograph of a model -- they crawl as the camera drifts.
|
||||
--
|
||||
-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two
|
||||
-- reasons that both come out of what the pass already is:
|
||||
--
|
||||
-- MSAA would take the water with it. The reflections read the frame's own
|
||||
-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth
|
||||
-- attachment is not a thing a fragment shader in this dialect can sample.
|
||||
-- The row would have quietly switched the other row off.
|
||||
--
|
||||
-- An edge filter (FXAA and its relatives) works from the finished colour
|
||||
-- alone, and would be GUESSING where the edges are out of one sample per
|
||||
-- pixel -- inventing detail it never rendered, and unable to tell a
|
||||
-- geometry edge from the boundary between two texels of a tileset.
|
||||
--
|
||||
-- Rendering the pass larger and folding it back down has neither problem:
|
||||
-- the depth buffer stays an ordinary texture, every pass in the frame keeps
|
||||
-- working in the canvas it was handed, and the fold is an average of samples
|
||||
-- that were each rendered honestly. It antialiases everything at once --
|
||||
-- geometry, the alpha-cut outline of a sprite card, the wireframe, the
|
||||
-- water's ray march -- because none of them know it is happening.
|
||||
--
|
||||
-- Be clear about what "everything" means: the artwork softens too. A tileset
|
||||
-- texel out here is not a screen pixel, it is a quad in a perspective view,
|
||||
-- and its boundary crosses the pixel grid at the same arbitrary angle a roof
|
||||
-- ridge does -- so the fold averages across it exactly as it averages across
|
||||
-- the ridge. That is what an honest extra sample says about that pixel, and
|
||||
-- it is also the trade the row IS: the diorama comes out smoother, not
|
||||
-- sharper. Which is why this is a row and not something that is simply on.
|
||||
--
|
||||
-- What it costs is pixels, which is the whole of why this is a row and not
|
||||
-- something that is simply on: 2X is half again as many in each direction,
|
||||
-- 4X is twice, and the scene pass is the most expensive thing in the frame.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local AntiAlias = {}
|
||||
|
||||
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||
-- OPTIONS and the mod manager's own settings page for this mod
|
||||
AntiAlias.KEY = "aa"
|
||||
AntiAlias.LABEL = "AA"
|
||||
|
||||
-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads
|
||||
-- everywhere else, and the canvas scale each rung costs is its square root:
|
||||
-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the
|
||||
-- size. OFF is the default -- this is a cost knob, and a mod should not
|
||||
-- quietly spend four times the fill rate of the machine it lands on.
|
||||
AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL,
|
||||
{ 0, 2, 4 }, { "OFF", "2X", "4X" })
|
||||
|
||||
-- The scale the pass currently open was actually expanded by (see expand).
|
||||
-- 1 while there is no supersampling in force, which is also what every
|
||||
-- reader gets on a frame that never opened a pass at all.
|
||||
local live = 1
|
||||
|
||||
function AntiAlias.samples()
|
||||
return tonumber(AntiAlias.setting:get()) or 0
|
||||
end
|
||||
|
||||
-- What the row ASKS for. The scale in force is `factor()`, which is this
|
||||
-- clamped to what the driver will actually allocate.
|
||||
local function wanted()
|
||||
local n = AntiAlias.samples()
|
||||
if n <= 1 then return 1 end
|
||||
return math.sqrt(n)
|
||||
end
|
||||
|
||||
-- The biggest canvas this driver admits to, or nil where it will not say.
|
||||
-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty
|
||||
-- of hardware and every phone -- and a refused canvas is not a softer
|
||||
-- diorama, it is beginScene returning false and the whole mode falling back
|
||||
-- to the flat 2D path.
|
||||
local function textureLimit()
|
||||
if not (love.graphics and love.graphics.getSystemLimits) then return nil end
|
||||
local ok, limits = pcall(love.graphics.getSystemLimits)
|
||||
return (ok and limits and limits.texturesize) or nil
|
||||
end
|
||||
|
||||
-- The size to render `w` x `h` display pixels at, and the size everything
|
||||
-- inside the pass then measures itself in.
|
||||
--
|
||||
-- Also where `live` is set, which is why this must be called once per pass
|
||||
-- immediately before beginScene: the wireframe's line width and the FX
|
||||
-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be
|
||||
-- multiplied up into canvas ones, and the honest multiplier is the one this
|
||||
-- returned rather than the one the row asked for.
|
||||
function AntiAlias.expand(w, h)
|
||||
local s = wanted()
|
||||
local max = textureLimit()
|
||||
if max and max > 0 then
|
||||
-- clamped rather than abandoned: a window too big for 4X can usually
|
||||
-- still carry some of it, and half a rung of smoothing is worth more
|
||||
-- than a row that silently does nothing at that size
|
||||
s = math.min(s, max / math.max(1, w), max / math.max(1, h))
|
||||
end
|
||||
if not (s > 1.01) then
|
||||
live = 1
|
||||
return w, h
|
||||
end
|
||||
local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5)
|
||||
live = ew / math.max(1, w)
|
||||
return ew, eh
|
||||
end
|
||||
|
||||
-- The scale the open pass was expanded by; 1 when it was not.
|
||||
function AntiAlias.factor()
|
||||
return live
|
||||
end
|
||||
|
||||
-- ------- the fold
|
||||
--
|
||||
-- One target per pass (the free-roam world and the battle's arena are alive
|
||||
-- at different moments but reallocating on every battle entry and exit is
|
||||
-- what the scene canvas's own slots exist to avoid), reallocated only when
|
||||
-- that pass's DISPLAY size changes -- a window resize, or the row itself
|
||||
-- moving, which changes the source and not this.
|
||||
|
||||
local targets = {}
|
||||
|
||||
local function targetFor(slot, w, h)
|
||||
local t = targets[slot]
|
||||
if not (t and t.w == w and t.h == h) then
|
||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||
if not (ok and c) then return nil end
|
||||
-- nearest, like the canvas it stands in for: this one is composited a
|
||||
-- canvas pixel to a display pixel, and the smoothing has already happened
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||
t = { canvas = c, w = w, h = h }
|
||||
targets[slot] = t
|
||||
end
|
||||
return t.canvas
|
||||
end
|
||||
|
||||
-- The box filter, and the whole of why it is a shader rather than a scaled
|
||||
-- draw with linear filtering on.
|
||||
--
|
||||
-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at
|
||||
-- the rungs below FULL a good deal of the frame is still that. Averaging a
|
||||
-- straight-alpha edge against it drags the result toward black as well as
|
||||
-- toward transparent, and then the engine's own composite multiplies by that
|
||||
-- alpha a second time -- so every silhouette against the void would come out
|
||||
-- ringed with a dark fringe, which is exactly the artefact the row is here to
|
||||
-- remove.
|
||||
--
|
||||
-- So the taps are premultiplied before they are averaged and divided back out
|
||||
-- after, which is the arithmetic that makes an edge pixel mean "half covered
|
||||
-- by this colour" instead of "covered by half of this colour".
|
||||
--
|
||||
-- Four taps, half a source texel from the destination centre. At 4X those
|
||||
-- land dead on the four texel centres the destination pixel covers, so it is
|
||||
-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear
|
||||
-- fetch under each tap widens the box a little rather than missing samples.
|
||||
local SHADER = [[
|
||||
uniform vec2 tap; // half a SOURCE texel, in uv
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y));
|
||||
vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y));
|
||||
vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y));
|
||||
vec4 d = Texel(tex, tc + vec2( tap.x, tap.y));
|
||||
float al = (a.a + b.a + c.a + d.a) * 0.25;
|
||||
if (al <= 0.0) return vec4(0.0);
|
||||
vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a;
|
||||
return vec4(sum * 0.25 / al, al) * color;
|
||||
}
|
||||
]]
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER)
|
||||
shader = (ok and sh) or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
-- Fold `canvas` down to `w` x `h` and hand back the result.
|
||||
--
|
||||
-- Returns the input untouched when there is nothing to fold -- the row is
|
||||
-- off, or the canvas already IS that size -- so a caller can run it
|
||||
-- unconditionally, and so can a headless test run. A target that would not
|
||||
-- allocate is the same answer: the pass is lost either way if this hands back
|
||||
-- something the wrong size, so it hands back the input and the frame draws at
|
||||
-- the size it was rendered.
|
||||
function AntiAlias.resolve(canvas, w, h, slot)
|
||||
if not canvas then return canvas end
|
||||
local ok, cw, ch = pcall(canvas.getDimensions, canvas)
|
||||
if not ok or (cw == w and ch == h) then return canvas end
|
||||
local target = targetFor(slot or "world", w, h)
|
||||
if not target then return canvas end
|
||||
|
||||
local sh = getShader()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
-- the scene canvas filters nearest for its usual 1:1 blit; the taps want
|
||||
-- linear, put back below so every other pass finds what it expects
|
||||
pcall(canvas.setFilter, canvas, "linear", "linear")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- replace, not alpha-blend: this is an image-processing copy, and the alpha
|
||||
-- the shader worked out has to land as itself rather than be composited
|
||||
-- against whatever the target held
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
if sh then
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch })
|
||||
end
|
||||
local drew = pcall(function()
|
||||
love.graphics.setCanvas(target)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch)
|
||||
end)
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||
return drew and target or canvas
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function AntiAlias.invalidate()
|
||||
for slot, t in pairs(targets) do
|
||||
if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||
targets[slot] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function AntiAlias.row()
|
||||
return AntiAlias.setting:row()
|
||||
end
|
||||
|
||||
return AntiAlias
|
||||
@@ -42,6 +42,20 @@ local quad = nil -- nil = untried, false = unavailable
|
||||
-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole
|
||||
-- texture. Feet on the model origin, so the model matrix only has to say
|
||||
-- where the mon is standing and how big it is.
|
||||
--
|
||||
-- Which puts this card OFF the voxel grid, alone among the meshes in this
|
||||
-- mode: the rest are built one unit per voxel in their own model space --
|
||||
-- terrain in world pixels, a character's card in the sprite's own pixels --
|
||||
-- and the wireframe is the integer planes of that space (see VoxelGrid).
|
||||
-- One unit here is the whole card, so the only integer plane inside it is
|
||||
-- x = 0, which is the pic's centre column: a single stray line straight
|
||||
-- down the middle of every Pokemon and no seams anywhere else.
|
||||
--
|
||||
-- The card stays a unit card, because a mon's size on screen is decided by
|
||||
-- the artwork's own dimensions and the distance it is standing at, and a
|
||||
-- unit card is what lets one matrix say both. Whoever draws it turns the
|
||||
-- wireframe off instead (Voxel3D.seams) -- a mesh that is not on the voxel
|
||||
-- grid does not get a voxel grid drawn on it.
|
||||
local function unitQuad()
|
||||
if quad ~= nil then return quad or nil end
|
||||
local verts = {
|
||||
@@ -93,8 +107,11 @@ function BattleBillboard.draw(tex, x, y, z, grow)
|
||||
if grow then w, h = w * grow, h * grow end
|
||||
if w <= 0 or h <= 0 then return false end
|
||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
||||
-- off the voxel grid, so no wireframe on it (see unitQuad)
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
|
||||
BattleBillboard.PULL)
|
||||
Voxel3D.seams(true)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
-- Leaving a battle: the fade the way back to the map never had.
|
||||
--
|
||||
-- Going IN is a whole production -- one of the original's eight wipes, picked by
|
||||
-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a
|
||||
-- hard cut: BattleState:finish pops itself and the map is simply THERE on the
|
||||
-- next frame. On the flat battle screen that is a cut between a white field and
|
||||
-- a tile map, which the original got away with. In this mode it is a cut between
|
||||
-- a placed camera looking across an arena and a diorama looking down on a
|
||||
-- walking player, and a jump that big reads as a glitch rather than as an edit.
|
||||
--
|
||||
-- So the battle fades out, closes behind the black, and the map fades up out of
|
||||
-- it. The timing is a transitions record this mod registers rather than a
|
||||
-- constant in here, so it is retunable in data like the engine's own eight.
|
||||
--
|
||||
-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a
|
||||
-- vanilla battle keeps the cut it always had. While the mode IS on, every battle
|
||||
-- gets it -- including one that found no arena and drew on the flat battle
|
||||
-- screen -- because what is being smoothed over is the return to the MAP, and
|
||||
-- the map is a diorama either way.
|
||||
--
|
||||
-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws
|
||||
-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED
|
||||
-- composite in a wrap around Renderer:endFrame paints it -- after the world
|
||||
-- blit, after the letterbox, after the UI blit, which is the only point where a
|
||||
-- single rect covers everything on screen at once.
|
||||
--
|
||||
-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted
|
||||
-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that
|
||||
-- borrowed it would darken the arena and leave the battle's text box sitting
|
||||
-- bright on top of the black -- and the letterbox bars of a flat battle screen,
|
||||
-- painted by the renderer's clear before any state draws, would not darken at
|
||||
-- all.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local BattleExit = {}
|
||||
BattleExit.__index = BattleExit
|
||||
|
||||
-- The battle underneath keeps drawing while this is up -- what fades is its own
|
||||
-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so
|
||||
-- nothing the battle does can outrun the fade either.
|
||||
BattleExit.isOpaque = false
|
||||
|
||||
-- The registered record's id, and the fallback timing if it is missing (a
|
||||
-- headless caller, or a total conversion that dropped the namespace). Per HALF,
|
||||
-- matching the engine's warp fade, so the whole edit is 24 frames.
|
||||
BattleExit.ID = "voxel_battle_exit"
|
||||
BattleExit.FRAMES = 12
|
||||
|
||||
-- The fade in progress, or nil. Kept here rather than on the state so the
|
||||
-- endFrame wrap has one place to look and nothing can go stale the frame after
|
||||
-- the state leaves the stack.
|
||||
local live = nil
|
||||
|
||||
-- How black the composite is this frame: 0 on the battle's last live frame, 1 at
|
||||
-- the cut, 0 again once the map is up. nil when no fade is running, which is
|
||||
-- every other frame the game ever draws.
|
||||
function BattleExit.veil()
|
||||
if not live then return nil end
|
||||
-- A fade can be taken off the stack by something that is not the fade: a
|
||||
-- script or a shot driver popping down to the overworld, a state teardown.
|
||||
-- Then it is not running, whatever its counter says -- and a veil left behind
|
||||
-- would black the game out for good, because nothing is going to fade it back
|
||||
-- in. Checked here rather than trusted, because this is the one place the
|
||||
-- answer is used. The walk only happens while a fade is live.
|
||||
local stack = live.game and live.game.stack
|
||||
local states = stack and stack.states
|
||||
local onStack = false
|
||||
for i = #(states or {}), 1, -1 do
|
||||
if states[i] == live then onStack = true break end
|
||||
end
|
||||
if not onStack then live = nil; return nil end
|
||||
local a = live.t / live.frames
|
||||
if live.phase == "in" then a = 1 - a end
|
||||
return math.max(0, math.min(1, a))
|
||||
end
|
||||
|
||||
local function framesFor(game)
|
||||
local records = game and game.data and game.data.transitions
|
||||
local record = records and records[BattleExit.ID]
|
||||
local frames = record and record.frames
|
||||
if type(frames) == "number" and frames > 0 then return frames end
|
||||
return BattleExit.FRAMES
|
||||
end
|
||||
|
||||
function BattleExit.new(game, battle, onMidpoint)
|
||||
return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint,
|
||||
frames = framesFor(game), t = 0, phase = "out" },
|
||||
BattleExit)
|
||||
end
|
||||
|
||||
-- Push the fade over the battle it is closing.
|
||||
function BattleExit.start(battle, onMidpoint)
|
||||
local game = battle.game
|
||||
local self = BattleExit.new(game, battle, onMidpoint)
|
||||
live = self
|
||||
game.stack:push(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function BattleExit:update()
|
||||
self.t = self.t + 1
|
||||
if self.t < self.frames then return end
|
||||
self.t = 0
|
||||
|
||||
if self.phase == "in" then
|
||||
live = nil
|
||||
self.game.stack:pop()
|
||||
return
|
||||
end
|
||||
|
||||
-- ------- the cut, at full black
|
||||
--
|
||||
-- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while
|
||||
-- this fade is up that is the fade -- so a fade that stayed would eat the
|
||||
-- battle's own pop and leave the battle running underneath, finished but
|
||||
-- still on the stack. Popping ourselves hands the top back to the battle so
|
||||
-- its pop lands on itself.
|
||||
self.phase = "in"
|
||||
local stack = self.game.stack
|
||||
stack:pop()
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
if stack:top() == self.game.overworld then
|
||||
stack:push(self) -- and the map comes up out of it
|
||||
return
|
||||
end
|
||||
|
||||
-- We are not going back to the map after all. Either the battle did not
|
||||
-- actually leave -- finish() can be a false start, and wanted() mirrors the
|
||||
-- one the engine has today -- or something else took the screen on the way
|
||||
-- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns
|
||||
-- the transition from here, so this one ends at the cut instead of fading in
|
||||
-- over the top of it. The flag goes back too, so a second finish() that does
|
||||
-- leave gets its own fade.
|
||||
live = nil
|
||||
if self.battle then self.battle.dramaticShapeLeaving = nil end
|
||||
end
|
||||
|
||||
-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a
|
||||
-- fault, and runnable on this machine. A function on the table rather than an
|
||||
-- inline call so a driver or a headless test can pin it -- the test harness has
|
||||
-- no depth buffer, where the honest answer is no on every rung.
|
||||
function BattleExit.modeOn()
|
||||
return require("src.render.Pipelines").eligible("voxel") and true or false
|
||||
end
|
||||
|
||||
-- Whether this ending gets the fade.
|
||||
function BattleExit.wanted(battle)
|
||||
local game = battle and battle.game
|
||||
if not (game and game.stack) then return false end
|
||||
-- finish() is not always the end: an unpaid PAY DAY prints its takings and
|
||||
-- comes back through here a moment later (BattleState:finish's first branch).
|
||||
-- Mirrored read-only, so the fade starts on the call that really leaves rather
|
||||
-- than fading to black and snapping back for one more message.
|
||||
if battle.payDay and battle.result == "win" then return false end
|
||||
return BattleExit.modeOn()
|
||||
end
|
||||
|
||||
-- ------- engine seams
|
||||
--
|
||||
-- Two wraps, each idempotent so a hot reload cannot stack them.
|
||||
function BattleExit.install()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
if not BattleState.dramaticShapeExitHook then
|
||||
local inner = BattleState.finish
|
||||
-- The one place a battle ends. Wrapped rather than listened for: the
|
||||
-- battle.ended event is emitted AFTER the pop, and by then the battle
|
||||
-- screen is gone and there is nothing left to fade out.
|
||||
function BattleState:finish()
|
||||
if self.dramaticShapeLeaving or not BattleExit.wanted(self) then
|
||||
return inner(self)
|
||||
end
|
||||
self.dramaticShapeLeaving = true
|
||||
BattleExit.start(self, function() inner(self) end)
|
||||
end
|
||||
BattleState.dramaticShapeExitHook = true
|
||||
end
|
||||
|
||||
local Renderer = require("src.render.Renderer")
|
||||
if not Renderer.dramaticShapeExitHook then
|
||||
local inner = Renderer.endFrame
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
inner(self, zones, worldZones)
|
||||
local a = BattleExit.veil()
|
||||
if not a or a <= 0 then return end
|
||||
-- The composite is on the screen by now, in LOVE units, so one rect over
|
||||
-- the window darkens the world, the letterbox bars, the text box and
|
||||
-- anything a present pass put on top, all by the same amount. Left to
|
||||
-- last on purpose: this is a shutter closing on the finished frame, not a
|
||||
-- layer inside it.
|
||||
local w, h = love.graphics.getDimensions()
|
||||
love.graphics.setColor(0, 0, 0, a)
|
||||
love.graphics.rectangle("fill", 0, 0, w, h)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
Renderer.dramaticShapeExitHook = true
|
||||
end
|
||||
end
|
||||
|
||||
return BattleExit
|
||||
+64
-5
@@ -201,6 +201,23 @@ local function frostRect(rect, box)
|
||||
return fx, fy, math.max(1, fw), math.max(1, fh)
|
||||
end
|
||||
|
||||
-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD
|
||||
-- snapped out to the window's edge has left the GB frame, so it has no GB
|
||||
-- coordinates to be placed from -- see OverworldBattle.snapRects.
|
||||
local function frostRectWorld(rect, box)
|
||||
local kx = frostW / box.pw
|
||||
local ky = frostH / box.ph
|
||||
return rect[1] * kx, rect[2] * ky,
|
||||
math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
|
||||
end
|
||||
|
||||
-- Which of the two the caller's rects are in. One frost buffer, one panel
|
||||
-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
|
||||
-- canvas) or world pixels (rects land in the window-resolution world image).
|
||||
local function mapper(world)
|
||||
return world and frostRectWorld or frostRect
|
||||
end
|
||||
|
||||
-- ------- the verdict
|
||||
--
|
||||
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
|
||||
@@ -211,11 +228,12 @@ end
|
||||
-- the tint below then commits both panels to that reading.
|
||||
local wasDark = false
|
||||
|
||||
function BattleHud.verdict(rects, box)
|
||||
function BattleHud.verdict(rects, box, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local toFrost = mapper(world)
|
||||
local darkest = nil
|
||||
for key, rect in pairs(rects) do
|
||||
local fx, fy, fw, fh = frostRect(rect, box)
|
||||
local fx, fy, fw, fh = toFrost(rect, box)
|
||||
local v = sampleLuma(key, fx, fy, fw, fh)
|
||||
if v and (not darkest or v < darkest) then darkest = v end
|
||||
end
|
||||
@@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box)
|
||||
return wasDark
|
||||
end
|
||||
|
||||
-- Draw one HUD panel into the current target, in GB coordinates.
|
||||
-- Draw one HUD panel into the current target, in that target's own
|
||||
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
|
||||
-- for a panel laid straight onto the world image.
|
||||
--
|
||||
-- The tint always pushes AWAY from the glyph colour that is about to be
|
||||
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
|
||||
-- gets darker under white text, a bright one brighter under black text.
|
||||
function BattleHud.panel(rect, box, dark)
|
||||
function BattleHud.panel(rect, box, dark, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local fx, fy, fw, fh = frostRect(rect, box)
|
||||
local fx, fy, fw, fh = mapper(world)(rect, box)
|
||||
local ok = pcall(function()
|
||||
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
||||
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
|
||||
@@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- ------- the whole HUD layer as a texture
|
||||
--
|
||||
-- The two blocks do not sit in the same place any more: each is snapped to its
|
||||
-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
|
||||
-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
|
||||
-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
|
||||
-- each block is then blitted out of it as a quad.
|
||||
--
|
||||
-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
|
||||
-- into the texture rather than composited into the caller's target -- the world
|
||||
-- image the quads land on is a colour canvas, and a flip pass over it would
|
||||
-- whiten the terrain behind the glyphs along with them.
|
||||
local hudLayer = nil
|
||||
|
||||
function BattleHud.layerTexture(w, h, dark, fn)
|
||||
if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
|
||||
hudLayer = canvasOf(w, h, "nearest")
|
||||
if not hudLayer then return nil end
|
||||
end
|
||||
local g = love.graphics
|
||||
local prevCanvas = g.getCanvas()
|
||||
local prevBlend, prevAlpha = g.getBlendMode()
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(hudLayer)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
-- flipGlyphs renders fn into its own scratch layer and composites the
|
||||
-- whitened result into whatever is bound, which is this canvas
|
||||
if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
|
||||
end)
|
||||
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
||||
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
return hudLayer
|
||||
end
|
||||
|
||||
-- The last luminance measured, for the shot driver's log.
|
||||
function BattleHud.lastLuma()
|
||||
local best = nil
|
||||
@@ -344,6 +402,7 @@ function BattleHud.invalidate()
|
||||
frostW, frostH = 0, 0
|
||||
luma = {}
|
||||
wasDark = false
|
||||
layer, hudLayer = nil, nil
|
||||
end
|
||||
|
||||
return BattleHud
|
||||
|
||||
+224
-34
@@ -9,12 +9,76 @@
|
||||
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
|
||||
-- hole with the world showing through, and the mon reads as a stencil.
|
||||
--
|
||||
-- So the paper is put back, and only where the paper was: the pic is read
|
||||
-- back once, the transparent region OUTSIDE the figure is flood-filled from
|
||||
-- the border, and every transparent pixel the flood could not reach -- every
|
||||
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
|
||||
-- untouched, so the mon still cuts cleanly against the world; only its
|
||||
-- insides stop being see-through.
|
||||
-- So the paper is put back, and only where the paper was. Which pixels those
|
||||
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
|
||||
-- the hardware drew the mon's white belly and the white field behind it with
|
||||
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
|
||||
-- image says which was which. There is no distinction to recover; there is one
|
||||
-- to draw.
|
||||
--
|
||||
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
|
||||
-- can reach is background, and whatever it cannot is paper. What makes that
|
||||
-- work is where the flood is allowed to start.
|
||||
--
|
||||
-- Start it at the image border and it fills everything and answers nothing.
|
||||
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
|
||||
-- out between two legs and off the bottom of the frame. Run over all 352 of
|
||||
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
|
||||
-- it left every mon a stencil, which is the bug this file exists to fix and
|
||||
-- for a long time did not.
|
||||
--
|
||||
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
|
||||
-- the left, the right and the top are seeded whole. The sky between a pair of
|
||||
-- ears reaches the top edge and stays sky; the gap between a body and a raised
|
||||
-- tail reaches the side and stays gap.
|
||||
--
|
||||
-- The BOTTOM is the interesting one, because two completely different things
|
||||
-- meet the underside of a figure and they have to be told apart.
|
||||
--
|
||||
-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
|
||||
-- on down until the artist stopped, leaking to the outside through the inch
|
||||
-- between a body and a leg. Seal it: what is above it is the mon.
|
||||
--
|
||||
-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
|
||||
-- that happens to be enclosed on three sides. Leave it open: the world
|
||||
-- should show through the gap in a trainer's stride.
|
||||
--
|
||||
-- What separates them is how WIDE the opening is, and on this game's art that
|
||||
-- is not a close call. Measured along the bottom of every battle pic: the
|
||||
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
|
||||
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
|
||||
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
|
||||
-- taken at 6 with room either side rather than tuned to a single sprite.
|
||||
--
|
||||
-- Apart from that one number the rule is exact: no pixel is filled for what
|
||||
-- surrounds it, only because the background provably cannot get to it. And it
|
||||
-- needs no idea whether it is holding a front pic, a back one or a trainer --
|
||||
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
|
||||
-- and they come back untouched because that is what their own shape says, not
|
||||
-- because they were special-cased.
|
||||
--
|
||||
-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a
|
||||
-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such
|
||||
-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own
|
||||
-- slot with its feet flush on the text box (BattleState.backPlacement pins
|
||||
-- row 96), so the only thing under its lowest row is white box. Nothing can
|
||||
-- reach it from below, whatever the opening's width, and the caller says so
|
||||
-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic
|
||||
-- and becomes exact: paper is whatever the background cannot walk to from the
|
||||
-- left, the right or the top.
|
||||
--
|
||||
-- That is the difference between a Pikachu that reads as a mon and one that
|
||||
-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong,
|
||||
-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is
|
||||
-- shade 0 and every one of them is keyed away, so the figure is a rim with the
|
||||
-- arena showing through it. Each one also has a wide opening along its bottom,
|
||||
-- which the drain cut correctly reads as a mouth and the sealed bottom
|
||||
-- correctly does not. Twelve of this game's 151 back pics turn on it; the
|
||||
-- other 139 come back byte-identical either way, because they had nothing
|
||||
-- under them the flood was getting in through.
|
||||
--
|
||||
-- The silhouette is untouched, so the mon still cuts cleanly against the
|
||||
-- world; only its insides stop being see-through.
|
||||
--
|
||||
-- Read back off the GPU rather than off the asset, deliberately. What comes
|
||||
-- back is the pic the engine actually decided to draw -- species palette,
|
||||
@@ -27,14 +91,23 @@ local V = ...
|
||||
|
||||
local BattlePics = {}
|
||||
|
||||
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
|
||||
-- out of scope takes its filled twin with it rather than pinning a texture
|
||||
-- for the session.
|
||||
local cache = setmetatable({}, { __mode = "k" })
|
||||
-- Cached by the image the engine handed over, one table per bottom rule --
|
||||
-- the same pic answers differently sealed and unsealed, and a single table
|
||||
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
|
||||
-- so a pic that goes out of scope takes its filled twin with it rather than
|
||||
-- pinning a texture for the session.
|
||||
local function newCache()
|
||||
return {
|
||||
[false] = setmetatable({}, { __mode = "k" }),
|
||||
[true] = setmetatable({}, { __mode = "k" }),
|
||||
}
|
||||
end
|
||||
local cache = newCache()
|
||||
|
||||
-- What an enclosed hole is filled with. White, because white is what the
|
||||
-- battle field was: this restores the pixel the artist drew and the engine
|
||||
-- then keyed away, it does not invent a new one.
|
||||
-- What an enclosed hole is filled with when the pic itself offers nothing
|
||||
-- better. White, because white is what the battle field was: this restores the
|
||||
-- pixel the artist drew and the engine then keyed away, it does not invent a
|
||||
-- new one.
|
||||
BattlePics.FILL = { 1, 1, 1, 1 }
|
||||
|
||||
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
||||
@@ -44,6 +117,21 @@ local CUT = 0.5
|
||||
-- its data back, so it is drawn into a canvas of its own size and the canvas
|
||||
-- is read -- which is also what makes this work for every path that produces
|
||||
-- a pic, without knowing which one produced this one.
|
||||
--
|
||||
-- The canvas is forced to dpiscale = 1, and that is the whole difference
|
||||
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
|
||||
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
|
||||
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
|
||||
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
|
||||
-- newImageData reads the magnified copy back at its own PIXEL size. The image
|
||||
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
|
||||
-- it trusts getWidth(), and the mon stands on the map nearly three times the
|
||||
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
|
||||
-- is already 1 there. Nor did every species, because only a pic with an
|
||||
-- enclosed hole in it comes back through here at all (see `changed` below) --
|
||||
-- so a Pidgey came out giant and the mon beside it did not, which is what
|
||||
-- makes this read as a sprite bug rather than a scale one. See the engine's
|
||||
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
|
||||
local function readBack(img)
|
||||
local w, h = img:getDimensions()
|
||||
if w <= 0 or h <= 0 then return nil end
|
||||
@@ -52,7 +140,7 @@ local function readBack(img)
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local data = nil
|
||||
local ok = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
@@ -72,32 +160,120 @@ local function readBack(img)
|
||||
return ok and data or nil
|
||||
end
|
||||
|
||||
-- Mark every transparent pixel reachable from the border. That set is the
|
||||
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
|
||||
-- The box the artwork actually occupies, or nil for a pic with no ink in it.
|
||||
--
|
||||
-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
|
||||
-- whole rows and columns of nothing around itself. The bottom of THIS box is
|
||||
-- the cut the rule below turns on, and the bottom of the image is just empty
|
||||
-- frame some distance under it.
|
||||
local function inkBounds(data, w, h)
|
||||
local x0, y0, x1, y1 = w, h, -1, -1
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a > CUT then
|
||||
if x < x0 then x0 = x end
|
||||
if x > x1 then x1 = x end
|
||||
if y < y0 then y0 = y end
|
||||
if y > y1 then y1 = y end
|
||||
end
|
||||
end
|
||||
end
|
||||
if x1 < x0 then return nil end
|
||||
return x0, y0, x1, y1
|
||||
end
|
||||
|
||||
-- The colour the keyed-away shade would have had: the LIGHTEST colour still
|
||||
-- standing in the pic.
|
||||
--
|
||||
-- Pure white is only the right answer while the pic is still grays, and by the
|
||||
-- time it reaches here it usually is not. picImage hands a pic over AFTER the
|
||||
-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the
|
||||
-- whole screen while the blackout text is up -- and shade 0 travels with the
|
||||
-- rest. A white belly inside a blacked-out mon would be the one lit thing on a
|
||||
-- dark screen; inside a warm-palette mon it would be a cold patch the artist
|
||||
-- never drew.
|
||||
--
|
||||
-- So the paper is read off the pic rather than assumed, which needs shade 0 to
|
||||
-- have survived somewhere in it. It always has: every one of this game's 151
|
||||
-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a
|
||||
-- cheek, the white of an eye -- because only the shade-0 pixels the decoder
|
||||
-- could reach were keyed. So what comes back is the baked shade 0 itself, not
|
||||
-- an approximation of it, and it tracks every palette the engine picks without
|
||||
-- being told which one that was.
|
||||
--
|
||||
-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps
|
||||
-- all three channels monotonically, so lightest by sum is lightest full stop.
|
||||
local function paperColor(data, x0, y0, x1, y1)
|
||||
local best, pr, pg, pb = -1, nil, nil, nil
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local r, g, b, a = data:getPixel(x, y)
|
||||
if a > CUT then
|
||||
local lum = r + g + b
|
||||
if lum > best then best, pr, pg, pb = lum, r, g, b end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best < 0 then return nil end
|
||||
return pr, pg, pb
|
||||
end
|
||||
|
||||
-- The widest opening along the bottom of a figure that still counts as a drain
|
||||
-- rather than a mouth. See the header for the measurements either side of it.
|
||||
BattlePics.DRAIN = 6
|
||||
|
||||
-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
|
||||
-- the edges of the artwork's box: the left, the right and the top whole, and
|
||||
-- along the bottom only those openings wide enough to be background rather
|
||||
-- than the underside of a figure the drawing ran out of -- or none of them at
|
||||
-- all, for a pic whose feet are on the text box and which therefore has
|
||||
-- nothing behind its lowest row to let in.
|
||||
--
|
||||
-- Confined to the box as well as seeded from it, so the empty frame under a
|
||||
-- short pic cannot walk around a sealed drain and come back up through it.
|
||||
--
|
||||
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
|
||||
-- pixels and a keyed-out background is most of them, which is a deeper call
|
||||
-- chain than is worth risking for no gain.
|
||||
local function markOutside(data, w, h)
|
||||
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local outside = {}
|
||||
local stack, top = {}, 0
|
||||
local function clear(x, y)
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
return a <= CUT
|
||||
end
|
||||
local function push(x, y)
|
||||
if x < 0 or y < 0 or x >= w or y >= h then return end
|
||||
if x < x0 or y < y0 or x > x1 or y > y1 then return end
|
||||
local key = y * w + x
|
||||
if outside[key] then return end
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a > CUT then return end
|
||||
if not clear(x, y) then return end
|
||||
outside[key] = true
|
||||
top = top + 1
|
||||
stack[top] = key
|
||||
end
|
||||
for x = 0, w - 1 do
|
||||
push(x, 0)
|
||||
push(x, h - 1)
|
||||
for x = x0, x1 do push(x, y0) end
|
||||
for y = y0, y1 do
|
||||
push(x0, y)
|
||||
push(x1, y)
|
||||
end
|
||||
for y = 0, h - 1 do
|
||||
push(0, y)
|
||||
push(w - 1, y)
|
||||
-- the bottom, run by run: a wide one is the gap between two legs and lets
|
||||
-- the world through, a narrow one is where a belly ran out and is sealed.
|
||||
-- Skipped whole for a pic on the box, where even the widest of them has
|
||||
-- white paper behind it rather than arena.
|
||||
if not sealBottom then
|
||||
local x = x0
|
||||
while x <= x1 do
|
||||
if clear(x, y1) then
|
||||
local from = x
|
||||
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||
if (x - from) > BattlePics.DRAIN then
|
||||
for k = from, x - 1 do push(k, y1) end
|
||||
end
|
||||
else
|
||||
x = x + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
while top > 0 do
|
||||
local key = stack[top]
|
||||
@@ -114,9 +290,15 @@ end
|
||||
-- The pic with its enclosed holes filled, or the pic itself when that could
|
||||
-- not be done (no pixel access, a driver that refused the readback). Never
|
||||
-- nil for a non-nil argument: a caller must always have something to draw.
|
||||
function BattlePics.filled(img)
|
||||
--
|
||||
-- sealBottom for a pic pinned to the text box rather than standing on the map:
|
||||
-- see the header. A caller that does not say defaults to the map, which is
|
||||
-- where all but one of this mod's pics are.
|
||||
function BattlePics.filled(img, sealBottom)
|
||||
if not img then return img end
|
||||
local hit = cache[img]
|
||||
sealBottom = sealBottom and true or false
|
||||
local slot = cache[sealBottom]
|
||||
local hit = slot[img]
|
||||
if hit ~= nil then return hit or img end
|
||||
|
||||
local made = nil
|
||||
@@ -124,16 +306,24 @@ function BattlePics.filled(img)
|
||||
local data = readBack(img)
|
||||
if not data then return end
|
||||
local w, h = data:getDimensions()
|
||||
local outside = markOutside(data, w, h)
|
||||
local x0, y0, x1, y1 = inkBounds(data, w, h)
|
||||
if not x0 then return end -- a pic with nothing drawn in it
|
||||
local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local fill = BattlePics.FILL
|
||||
local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
|
||||
local fr = pr or fill[1]
|
||||
local fg = pg or fill[2]
|
||||
local fb = pb or fill[3]
|
||||
local changed = false
|
||||
for y = 0, h - 1 do
|
||||
-- only inside the box: everything beyond it is frame the artist never
|
||||
-- reached, and filling that would put the mon in a white rectangle
|
||||
for y = y0, y1 do
|
||||
local row = y * w
|
||||
for x = 0, w - 1 do
|
||||
for x = x0, x1 do
|
||||
if not outside[row + x] then
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a <= CUT then
|
||||
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
|
||||
data:setPixel(x, y, fr, fg, fb, fill[4])
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
@@ -146,12 +336,12 @@ function BattlePics.filled(img)
|
||||
made = out
|
||||
end)
|
||||
|
||||
cache[img] = (ok and made) or false
|
||||
slot[img] = (ok and made) or false
|
||||
return made or img
|
||||
end
|
||||
|
||||
function BattlePics.invalidate()
|
||||
cache = setmetatable({}, { __mode = "k" })
|
||||
cache = newCache()
|
||||
end
|
||||
|
||||
return BattlePics
|
||||
|
||||
+133
-17
@@ -1,4 +1,4 @@
|
||||
-- Overworld battles: one frame of the arena, as geometry.
|
||||
-- Overworld battles: one frame of the arena, as geometry.
|
||||
--
|
||||
-- The same world the free-roam mode draws, from a placed camera instead of
|
||||
-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
|
||||
@@ -41,7 +41,10 @@ local VoxelScene = V.require("VoxelScene")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local BattleScene = {}
|
||||
|
||||
@@ -139,9 +142,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
|
||||
@@ -215,13 +219,18 @@ BattleScene.monCards = monCards
|
||||
local function shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
local host = arena.map or state.map
|
||||
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
|
||||
tostring(terrain), tostring(token or 0) }
|
||||
tostring(terrain), tostring(token or 0),
|
||||
-- the cycle keeps running through a fight, and an arena lit
|
||||
-- from somewhere new must be re-cast from there
|
||||
math.floor(ShadowMap.KX * 128),
|
||||
math.floor(ShadowMap.KZ * 128) }
|
||||
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
|
||||
return table.concat(parts, ",")
|
||||
end
|
||||
|
||||
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
atlasFor, cards, token, host, neighbors)
|
||||
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
|
||||
@@ -231,18 +240,35 @@ 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
|
||||
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil)
|
||||
-- the water surface is its own reflective pass now (see Water) and so is
|
||||
-- no longer inside the terrain mesh; the sun still has to see it, or the
|
||||
-- light's map has a hole at every lake
|
||||
ShadowMap.draw(water, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
|
||||
-- keep contact with their bases instead of starting a bias-width away
|
||||
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(neighbors) do
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
|
||||
-- the mons themselves, as the same cards the camera will see. Their alpha
|
||||
-- is the silhouette, so what lands on the ground is the shape of the
|
||||
-- Pokemon rather than a blob standing in for one.
|
||||
-- marked as the CAST, so a fight staged at the water's edge does not lay a
|
||||
-- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's
|
||||
-- own floor still takes them, which is the shadow that matters here
|
||||
ShadowMap.sprites(true)
|
||||
for _, card in ipairs(cards or {}) do
|
||||
ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model)
|
||||
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
|
||||
ShadowMap.snug(card.model))
|
||||
end
|
||||
ShadowMap.sprites(false)
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -289,18 +315,61 @@ 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)
|
||||
local host = arena.map or state.map
|
||||
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
|
||||
|
||||
-- the hour's light reaches the arena exactly as it reaches free-roam: the
|
||||
-- shared rig follows the clock on an outdoor floor and stays at noon on an
|
||||
-- indoor one, and the same tint multiplies the staged shot -- with the
|
||||
-- same window glass on whatever buildings stand in the background
|
||||
local outdoor = host.def and Map.isOutdoor(host.def) or false
|
||||
DayNight.applyRig(outdoor)
|
||||
-- a canopy floor (Viridian Forest) fights under the hour's tint too,
|
||||
-- with the rig and the void exactly as they were
|
||||
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host))
|
||||
local GlassMask = V.require("GlassMask")
|
||||
Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil
|
||||
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
||||
-- no glint in the arena: the drift is the shot breathing, not the player
|
||||
-- moving, and a shimmer on background windows would fight the mons
|
||||
Voxel3D.glassGlint = 0
|
||||
|
||||
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
|
||||
-- exactly the meshes walking around would have and nothing extra
|
||||
local terrain, nbMesh = 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()
|
||||
@@ -330,7 +399,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
|
||||
@@ -342,9 +411,12 @@ function BattleScene.render(state, arena, textures, token)
|
||||
|
||||
Voxel3D.camera = cam
|
||||
-- the sun is turned up for the arena and put back afterwards, so the
|
||||
-- free-roam world it shares this module with keeps its own weight
|
||||
-- free-roam world it shares this module with keeps its own weight -- and
|
||||
-- the hour still has the last word: a sunset fades the arena's shadows
|
||||
-- out and the moon presses more softly, exactly as it does outside
|
||||
local sunWas = Voxel3D.SHADOW_ALPHA
|
||||
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
|
||||
* DayNight.shadowScale(outdoor)
|
||||
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
|
||||
-- arena is a staged shot rather than the world being walked through, and
|
||||
-- the seams are what make it read as built rather than photographed. Forced
|
||||
@@ -356,7 +428,16 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
-- free-roam pass does too, but the two are alive at different moments
|
||||
-- and a shared slot would reallocate on every battle entry and exit
|
||||
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
|
||||
--
|
||||
-- AA, if the row asks for it, renders it larger still and folds it back
|
||||
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
|
||||
-- the lens was widened by the window's RATIO to the letterbox and the
|
||||
-- rig solved in the GB's own frame, so a bigger canvas is more samples
|
||||
-- of the identical shot -- which is why the pins below still measure in
|
||||
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
|
||||
-- folded canvas afterwards, stay the chunky GB art they are.
|
||||
local rw, rh = AntiAlias.expand(pw, ph)
|
||||
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
|
||||
return
|
||||
end
|
||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||
@@ -364,6 +445,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
|
||||
@@ -378,10 +475,22 @@ function BattleScene.render(state, arena, textures, token)
|
||||
if flashing then
|
||||
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
|
||||
end
|
||||
-- and no voxel wireframe on the pair. Everything else in this frame is
|
||||
-- built a unit per voxel and wears the seams that fall out of that; a
|
||||
-- mon's card is one quad wearing the battle screen (see
|
||||
-- BattleBillboard), so it is off the grid and has no seams to draw.
|
||||
Voxel3D.seams(false)
|
||||
-- and no glass either: the cards wear the battle screen, not the
|
||||
-- tileset atlas, so the mask's coordinates mean nothing on them
|
||||
Voxel3D.glass(false)
|
||||
for _, card in ipairs(monCards(arena, groundY, textures)) do
|
||||
-- the sun stored this card snugged (castShadows), so its own shadow
|
||||
-- lookup must read the same snugged transform -- see ShadowMap.snug
|
||||
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
||||
BattleBillboard.PULL)
|
||||
BattleBillboard.PULL, ShadowMap.snug(card.model))
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
if flashing then Voxel3D.flatten(nil) end
|
||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||
-- gives them, measured against THIS camera's pitch rather than the
|
||||
@@ -394,12 +503,14 @@ function BattleScene.render(state, arena, textures, token)
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull)
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull)
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
local canvas = Voxel3D.endScene()
|
||||
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
|
||||
if not canvas then return end
|
||||
|
||||
local vp = Voxel3D.vp
|
||||
@@ -430,6 +541,11 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
||||
-- the two marks rather than on a fraction of the window
|
||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
||||
-- and the hour's light, for anything drawn over this shot that is NOT
|
||||
-- geometry and so never went past the shader that applied it -- the back
|
||||
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
|
||||
-- indoors, which is what DayNight.tint answers for a room.
|
||||
tint = Voxel3D.tint,
|
||||
}
|
||||
end)
|
||||
-- the placed camera is ours for exactly this pass; anything else that
|
||||
|
||||
+632
-16
@@ -68,6 +68,47 @@ local RECESS_MAX = 24
|
||||
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
|
||||
side = 0.78, bottom = 0.5 }
|
||||
|
||||
-- ------- how far a merged run may reach: the tile lattice
|
||||
--
|
||||
-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
|
||||
-- straight projection a run may be as long as it likes -- a straight line
|
||||
-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
|
||||
-- STRAIGHT. It drops every vertex by the square of its distance from the
|
||||
-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
|
||||
-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
|
||||
-- the short quads butted against it, and the join tears open.
|
||||
--
|
||||
-- Nothing bounded a run's length before, and the runs that ran away were
|
||||
-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
|
||||
-- fascia, the shaded underside -- because a flat run has no art to break
|
||||
-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
|
||||
-- three world pixels under the roof surface beside it: the eave tore off
|
||||
-- the roof and the drop showed the building's dark interior through the
|
||||
-- slot. (Strip runs, the drawing marching along the atlas, break at the
|
||||
-- tileset's own boundaries and were never the problem.)
|
||||
--
|
||||
-- So a run stops at the next 8px lattice line. Buildings are stamped at
|
||||
-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
|
||||
-- scene -- terrain, props, this -- now ends on the same lines, every join
|
||||
-- is vertex-for-vertex, and the bend carries them together. What is left
|
||||
-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
|
||||
-- world pixel at any rung.
|
||||
--
|
||||
-- It costs quads on a dense city map (Cerulean's object stream goes from
|
||||
-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
|
||||
-- whether the curve is on or not, which is the deliberate trade: the mesh
|
||||
-- is cached per map and built asynchronously over seconds, so meshing for
|
||||
-- the curve's sake only when the curve is on would mean rebuilding every
|
||||
-- live map on a keypress.
|
||||
local CELL = 8
|
||||
|
||||
-- How far a run starting at `a` may go before it crosses the next lattice
|
||||
-- line. Floor-mod, so the awning's negative z lands on the same lines the
|
||||
-- positive side does.
|
||||
local function runCap(a)
|
||||
return CELL - a % CELL
|
||||
end
|
||||
|
||||
local function keyOf(tx, ty)
|
||||
return (ty + 64) * 4096 + (tx + 64)
|
||||
end
|
||||
@@ -170,6 +211,39 @@ local function read(t, data, perRow)
|
||||
|
||||
local inside = {}
|
||||
for i = 0, W * H - 1 do inside[i] = not outside[i] end
|
||||
|
||||
-- `scrub` names pixel rects where the drawing paints an object standing
|
||||
-- ON the surface (Red's potted plant on the dining tabletop). The object
|
||||
-- keeps its own standee -- the template's `keep` leaves its tiles
|
||||
-- unclaimed -- so the band beneath it is the one surface the drawing
|
||||
-- implies but never paints clear: every rect pixel takes the field
|
||||
-- shade, sourced from the first field texel outside the rects, and the
|
||||
-- model's top comes out as the plain surface the object sat on.
|
||||
if t.scrub then
|
||||
local function inRect(x, y)
|
||||
for _, r in ipairs(t.scrub) do
|
||||
if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local donor = nil
|
||||
for i = 0, W * H - 1 do
|
||||
if col[i] == GREY and inside[i]
|
||||
and not inRect(i % W, math.floor(i / W)) then
|
||||
donor = i
|
||||
break
|
||||
end
|
||||
end
|
||||
for i = 0, W * H - 1 do
|
||||
if inRect(i % W, math.floor(i / W)) then
|
||||
col[i] = GREY
|
||||
ax[i], ay[i] = ax[donor], ay[donor]
|
||||
inside[i] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
|
||||
end
|
||||
|
||||
@@ -190,7 +264,24 @@ local function measure(sp, t)
|
||||
top[x] = r
|
||||
end
|
||||
|
||||
local wallH = H - roofRows
|
||||
-- The drawing's own ground line: the row after the last drawn one. A
|
||||
-- building ends on the black threshold row it stands on (ground == H),
|
||||
-- but furniture is drawn standing on open floor -- the lab table's
|
||||
-- legs stop two rows short of its grid -- and extruding against H
|
||||
-- would float it that far above its own plot.
|
||||
local ground = roofRows
|
||||
for sy = H - 1, roofRows, -1 do
|
||||
local drawn = false
|
||||
for sx = 0, W - 1 do
|
||||
if sp.inside[sy * W + sx] then drawn = true break end
|
||||
end
|
||||
if drawn then
|
||||
ground = sy + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local wallH = ground - roofRows
|
||||
local ytop = wallH - 1 + t.slab
|
||||
|
||||
-- Side faces must not come out as slabs of outline black: where the
|
||||
@@ -259,6 +350,14 @@ local function measure(sp, t)
|
||||
end
|
||||
end
|
||||
|
||||
-- The pane rule reads a LIGHT region the drawing seals behind a BLACK
|
||||
-- frame. A drawing built the other way round -- the healing machine's
|
||||
-- dark screens sealed behind their own white bezels -- inverts under
|
||||
-- it: every lit edge sinks and the black panes stand proud, a black
|
||||
-- lattice a voxel off the face. `panes = false` says the drawing does
|
||||
-- not carry the rule's polarity, so the facade stays flush.
|
||||
if t.panes == false then recess = {} end
|
||||
|
||||
-- One representative texel per shade, taken from the building's own art:
|
||||
-- the roof's fascia and its undersides are geometry the drawing implies
|
||||
-- but never paints, and they must still wear its palette (and pick up
|
||||
@@ -279,20 +378,510 @@ local function measure(sp, t)
|
||||
-- sprite taller than its footprint -- the tower's 16-row drawing
|
||||
-- stands on the 8 rows of it that are actually on the map, and D = H
|
||||
-- would have pushed its body 64px south into the town plaza.
|
||||
return { top = top, ytop = ytop, D = #t.tiles * 8,
|
||||
-- `depth` (in tile rows) names the plot when the grid runs PAST it
|
||||
-- onto ground the drawing merely stands its legs on: the lab table's
|
||||
-- third row is the walkable cell the player faces it from, and the
|
||||
-- full-grid depth would stand the model in their path.
|
||||
-- `depth` names the plot in TILE ROWS, which is the right grain for a
|
||||
-- building. `depthPx` names it in voxels, for an object whose real
|
||||
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
|
||||
-- standing in the middle of its own cell, not a thing that fills a plot.
|
||||
return { top = top, ytop = ytop,
|
||||
D = t.depthPx or ((t.depth or #t.tiles) * 8),
|
||||
ground = ground,
|
||||
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- build --
|
||||
|
||||
-- A desk with separately-classified objects on it (a template's `parts`
|
||||
-- list): the methodology's region classification at part granularity.
|
||||
-- Upright parts anchor their drawn bottom row to the desk's top plane
|
||||
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
|
||||
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
|
||||
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
|
||||
-- the drawing is its position ON the desk. The desk is the lab-table
|
||||
-- slab + base; its lid is the one synthesized surface in the model
|
||||
-- (the objects cover every drawn pixel of the tabletop), continued
|
||||
-- from the sibling tables' pattern in the drawing's own shades.
|
||||
-- tools/building_voxels.py `build_desk_set` is the reference twin.
|
||||
local function deskSetModel(sp, pr, t)
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local ground = pr.ground
|
||||
local col, inside = sp.col, sp.inside
|
||||
local vox = {}
|
||||
local function key(x, y, z) return (y * D + z) * W + x end
|
||||
local function put(x, y, z, i) vox[key(x, y, z)] = i end
|
||||
|
||||
-- de-outline walk bounded to the part, so a part's side faces show
|
||||
-- its own material and never the neighbour's (the sprite-wide walk
|
||||
-- the facade path uses would cross the black seam between units)
|
||||
local function interiorAt(sx, sy, lo, hi)
|
||||
local i = sy * W + sx
|
||||
if col[i] ~= BLACK then return sx end
|
||||
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
|
||||
for d = 1, 3 do
|
||||
local nx = sx + step * d
|
||||
if nx >= lo and nx <= hi then
|
||||
local ni = sy * W + nx
|
||||
if inside[ni] and col[ni] ~= BLACK then return nx end
|
||||
end
|
||||
end
|
||||
return sx
|
||||
end
|
||||
|
||||
-- The parts list, shared by every base piece: a desk plane or an
|
||||
-- open tray rim alike, `plane` is simply the height they ride.
|
||||
local ytop = 0
|
||||
local function buildParts(plane)
|
||||
for _, p in ipairs(t.parts) do
|
||||
Budget.tick()
|
||||
local x0, x1 = p.x[1], p.x[2]
|
||||
if p.kind == "flat" then
|
||||
-- drawn row = depth row by default; `z` renames the origin when
|
||||
-- the flat sits below the desk's own drawn top span (the Center
|
||||
-- PC's keyboard). `at` names the sheet's own height when it does
|
||||
-- not lie on the desk plane (the healing machine's keyboard is a
|
||||
-- shelf mounted on the cabinet's side); `thick` gives it a body
|
||||
-- -- layers below the sheet repeating each column's own texel,
|
||||
-- the same continuation rule every synthesized surface follows.
|
||||
local r0 = p.rows[1]
|
||||
local z0 = p.z or r0
|
||||
local atY = p.at or plane
|
||||
local thick = p.thick or 1
|
||||
if atY > ytop then ytop = atY end
|
||||
for sy = r0, p.rows[2] do
|
||||
local z = z0 + (sy - r0)
|
||||
if z >= 0 and z < D then
|
||||
for sx = x0, x1 do
|
||||
if inside[sy * W + sx] then
|
||||
for y = math.max(0, atY - thick + 1), atY do
|
||||
put(sx, y, z, sy * W + sx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "box" then
|
||||
-- A BOX part is a drawn rect standing at its own drawn
|
||||
-- elevation -- equipment attached to the machine rather than an
|
||||
-- object on the desk plane. The rows are face-on art: the top
|
||||
-- row's drawn height IS the box's top (ground - 1 - r0,
|
||||
-- measured), and the box runs down to `base` (default the drawn
|
||||
-- extent; 0 continues it to the floor, the legs-continue rule).
|
||||
-- Height beyond the drawn rows fills the way a roof band does:
|
||||
-- rows before `cycle` map 1:1 from the top, rows after it 1:1
|
||||
-- from the bottom -- the healing machine hoses' foot lands ON
|
||||
-- the floor -- and the cycle window repeats between.
|
||||
local r0, r1 = p.rows[1], p.rows[2]
|
||||
local c0 = p.cycle and p.cycle[1] or r1
|
||||
local c1 = p.cycle and p.cycle[2] or r1
|
||||
local pz = p.z or 0
|
||||
local pd = p.depth
|
||||
local top = pr.ground - 1 - r0
|
||||
local bot = p.base or (pr.ground - 1 - r1)
|
||||
local nTop, nBot = c0 - r0, r1 - c1
|
||||
if top > ytop then ytop = top end
|
||||
for y = bot, top do
|
||||
local k, j = top - y, y - bot
|
||||
local sy
|
||||
if k < nTop then
|
||||
sy = r0 + k
|
||||
elseif j < nBot then
|
||||
sy = r1 - j
|
||||
else
|
||||
sy = c0 + (k - nTop) % (c1 - c0 + 1)
|
||||
end
|
||||
for sx = x0, x1 do
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local ix = interiorAt(sx, sy, x0, x1)
|
||||
for z = pz, pz + pd - 1 do
|
||||
if z >= 0 and z < D then
|
||||
local px = (z == pz or z == pz + pd - 1) and sx or ix
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif p.kind == "iso" then
|
||||
-- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
|
||||
-- degrees to the map, so one rhombus carries its top, its front
|
||||
-- and its side at once and no band or facade split can reach
|
||||
-- them. Un-projecting it is that projection run backwards: the
|
||||
-- box stands as a real diamond in plan and every voxel wears the
|
||||
-- texel the drawing paints where that voxel projects TO. The
|
||||
-- drawn top lands on the top, the screen on the screen-facing
|
||||
-- side and the flank on the flank, and nothing is segmented by
|
||||
-- hand -- which is the only way to get this right, because the
|
||||
-- three faces meet on a diagonal no rectangle can name.
|
||||
--
|
||||
-- Everything but the depth centre falls out of the drawn rect,
|
||||
-- because the projection fixes it: the half-width is the drawn
|
||||
-- rhombus's x radius, HALF that again its z radius (2:1 is what
|
||||
-- makes it isometric), the near corner's drawn row is the base
|
||||
-- rhombus's front tip, and whatever drawn height is left once
|
||||
-- that rhombus is accounted for is the box's own height. Bill's
|
||||
-- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
|
||||
-- which puts its left corner's vertical edge at drawn rows
|
||||
-- 4..10, exactly where the drawing paints one.
|
||||
--
|
||||
-- `plan` is the one thing the drawing CANNOT state: 2:1 is the
|
||||
-- projection, not the object, so reading rz as the plan radius
|
||||
-- too builds a box half as deep as it is wide -- a slab, not the
|
||||
-- cube the drawing depicts. `plan` names the real z radius and
|
||||
-- the drawn row is scaled into it, so a cube is `plan = rx` and
|
||||
-- the drawing still lands on it pixel for pixel.
|
||||
local pr0, pr1 = p.rows[1], p.rows[2]
|
||||
local rx = math.floor((x1 - x0 + 1) / 2)
|
||||
local rz = math.floor(rx / 2)
|
||||
local plan = p.plan or rz
|
||||
local oy = pr1 - rz
|
||||
local h = oy - rz - pr0
|
||||
local ytp = plane + h
|
||||
if ytp > ytop then ytop = ytp end
|
||||
for sx = x0, x1 do
|
||||
-- doubled, so a rect of even width keeps its centre between
|
||||
-- two columns instead of limping one to the left
|
||||
local dx2 = 2 * sx - (x0 + x1)
|
||||
for dz = -plan, plan do
|
||||
local z = p.z + dz
|
||||
local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
|
||||
if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
|
||||
-- the plan row scaled back into the drawn rhombus
|
||||
local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
|
||||
for y = 0, h do
|
||||
local sy = oy + dzs - y
|
||||
local i = sy * W + sx
|
||||
if sy >= pr0 and sy <= pr1 and inside[i] then
|
||||
put(sx, plane + y, z, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local tr0, tr1 = p.top[1], p.top[2]
|
||||
local fr0, fr1 = p.facade[1], p.facade[2]
|
||||
local pd = p.depth
|
||||
-- `rise` lifts a part off the desk's top plane and `z` names its
|
||||
-- back-most depth row (the field a flat part already carries). An
|
||||
-- object STANDING on a desk needs neither: it starts on the plane
|
||||
-- at the plot's back. The healing machine's console needs both --
|
||||
-- it stands in the FRONT map row of a grid whose back row is the
|
||||
-- wall band it leans against, and its screen head is MOUNTED on
|
||||
-- the console's front two voxels above the body's top. Both come
|
||||
-- off the drawing, not off taste.
|
||||
local base = plane + (p.rise or 0)
|
||||
local pz = p.z or 0
|
||||
local ytp = base + (fr1 - fr0)
|
||||
if ytp > ytop then ytop = ytp end
|
||||
-- `inset` sinks an authored pane one voxel: the pane rule
|
||||
-- applied by hand, for a part whose screen IS sealed behind its
|
||||
-- own black frame while the template's `panes = false` (set for
|
||||
-- the polarity-inverted panel elsewhere in the same drawing)
|
||||
-- blocks the global pass. Same mechanism as a recess: the front
|
||||
-- voxel is simply not placed.
|
||||
local ins = p.inset
|
||||
for sx = x0, x1 do
|
||||
-- the lid: the part's drawn top laid across its depth from the
|
||||
-- back, last row continuing forward; the front lid row is the
|
||||
-- facade's own top row -- the drawn front-top edge. `stretch`
|
||||
-- maps the drawn band over the whole depth instead, the tray's
|
||||
-- rule: for a part authored DEEPER than its drawing (the house
|
||||
-- stool grown past its drawn seat), clamping would print the
|
||||
-- last row as a long smear off the back band's edge.
|
||||
for z = pz, pz + pd - 1 do
|
||||
local front = z == pz + pd - 1
|
||||
local sy
|
||||
if front then
|
||||
sy = fr0
|
||||
elseif p.stretch then
|
||||
sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
|
||||
/ (pd - 1)), tr1)
|
||||
else
|
||||
sy = math.min(tr0 + z - pz, tr1)
|
||||
end
|
||||
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
|
||||
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
|
||||
if ok and z >= 0 and z < D then
|
||||
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
|
||||
end
|
||||
end
|
||||
-- the body: facade rows anchored to the part's own base
|
||||
for sy = fr0 + 1, fr1 do
|
||||
local y = base + (fr1 - sy)
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local ix = interiorAt(sx, sy, x0, x1)
|
||||
for z = pz, pz + pd - 1 do
|
||||
if z >= 0 and z < D then
|
||||
if z == pz + pd - 1 then
|
||||
local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
|
||||
and sy >= ins.rows[1] and sy <= ins.rows[2]
|
||||
if not sunk and not pr.recess[i] then put(sx, y, z, i) end
|
||||
elseif z == pz then
|
||||
put(sx, y, z, i)
|
||||
else
|
||||
put(sx, y, z, sy * W + ix)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A TRAY is an open container -- the drawing looks down INTO it, so its
|
||||
-- top-view band is not a lid but the inside of the box, and the model
|
||||
-- has to be hollow. Bands, all measured 1:1 like any other band table:
|
||||
-- `top` is the opening (drawn row -> depth row), `front` the near wall
|
||||
-- seen face-on (drawn row -> elevation), `x` the box's outer span and
|
||||
-- `inner` the opening's, so the difference between them is the wall.
|
||||
-- Four walls stand to the rim, the floor slab lies `floor` voxels thick
|
||||
-- under the opening, and the cavity between them is left as AIR -- which
|
||||
-- is the whole point, and what an extruded facade can never be. Parts (a
|
||||
-- standing lid) then ride the rim like any object on a desk's plane.
|
||||
if t.tray then
|
||||
local tr = t.tray
|
||||
local top0 = tr.top[1]
|
||||
local fr0, fr1 = tr.front[1], tr.front[2]
|
||||
local bx0, bx1 = tr.x[1], tr.x[2]
|
||||
local ix0, ix1 = tr.inner[1], tr.inner[2]
|
||||
local floor = tr.floor or 0
|
||||
local plane = fr1 - fr0 + 1 -- the rim: the wall's height
|
||||
-- Which drawn row lies at depth z. The far rim is the band's first
|
||||
-- row and the near rim the front wall's own, and the drawn inside
|
||||
-- STRETCHES over whatever depth is between them: a box deeper than
|
||||
-- its drawing has rows to spare is the ordinary case once the plot
|
||||
-- stops being the grid, and the alternative -- running out of rows
|
||||
-- and repeating the last one -- would print the wrench twice.
|
||||
local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside
|
||||
local span = math.max(1, D - 3) -- interior depth rows - 1
|
||||
local function trayRow(z)
|
||||
if z == 0 then return top0 end
|
||||
if z == D - 1 then return fr0 end
|
||||
return lo + math.floor((z - 1) * (hi - lo) / span)
|
||||
end
|
||||
for sx = bx0, bx1 do
|
||||
Budget.tick()
|
||||
for z = 0, D - 1 do
|
||||
local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
|
||||
for y = 0, (hollow and floor or plane - 1) do
|
||||
if hollow or y == plane - 1 then
|
||||
-- the opening seen from above: the tray's own floor and
|
||||
-- whatever lies in it -- and the rim is the same band where
|
||||
-- the wall meets it
|
||||
local i = trayRow(z) * W + sx
|
||||
if inside[i] then put(sx, y, z, i) end
|
||||
else
|
||||
-- the wall below the rim: the front band folded up it, the
|
||||
-- drawn face on the front and back layers and the de-outlined
|
||||
-- interior between, exactly as a facade extrudes.
|
||||
--
|
||||
-- NO recess pass here, and it must stay that way: a pane sinks
|
||||
-- by DELETING its front voxel so the one behind becomes the
|
||||
-- pane, and a container's wall is one voxel thick -- there is
|
||||
-- nothing behind it, so the front panel simply opened a hole
|
||||
-- straight into the box and you could see the wrench through it.
|
||||
local sy = fr1 - y
|
||||
local i = sy * W + sx
|
||||
if inside[i] then
|
||||
local px = (z == 0 or z == D - 1) and sx
|
||||
or interiorAt(sx, sy, bx0, bx1)
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if plane > ytop then ytop = plane end
|
||||
buildParts(plane)
|
||||
return { at = function(x, y, z)
|
||||
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
|
||||
return nil
|
||||
end
|
||||
return vox[key(x, y, z)]
|
||||
end,
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- No base piece at all: the drawing IS its parts (the house stool -- a
|
||||
-- seat and its legs, nothing under them but floor). The plane the parts
|
||||
-- anchor to is the ground itself.
|
||||
if not t.desk then
|
||||
buildParts(0)
|
||||
return { at = function(x, y, z)
|
||||
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
|
||||
return nil
|
||||
end
|
||||
return vox[key(x, y, z)]
|
||||
end,
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- The desk's top plane. Usually the drawing states it: the fascia and
|
||||
-- base rows it paints below the objects ARE the front face, and their
|
||||
-- row count is the height. Bill's desk paints neither inside its grid
|
||||
-- -- its apron is drawn into the WALKABLE cell in front, and that cell
|
||||
-- is left out on purpose so the chair standing there keeps its own
|
||||
-- tiles -- so `plane` names the height directly and the body below the
|
||||
-- lid is synthesized: the band table's own rim treatment, a shaded box
|
||||
-- closed by the outline where it meets the floor, in the drawing's
|
||||
-- shades via shadeTexel.
|
||||
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
|
||||
local b0, b1 = t.desk.base[1], t.desk.base[2]
|
||||
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
||||
|
||||
-- The desk's own PLOT, when the grid holds more than the desk. Bill's
|
||||
-- grid runs on into the walkable cell, because the drawing puts the
|
||||
-- desk's apron AND the chair pushed up to it in the same tiles -- so
|
||||
-- the desk box has to stop at its own cell (`depth`) and stand on its
|
||||
-- own ground line rather than the grid's, which the chair's feet set
|
||||
-- eight rows lower. The base band's last row IS that ground line by
|
||||
-- definition, and for every desk drawn inside its own grid it is the
|
||||
-- measured one to the row (lab table, lab computers, Center PC, the
|
||||
-- Bike Shop toolbox), so this changes nothing for them.
|
||||
-- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
|
||||
-- is shallower than a tile row and leans against something: the
|
||||
-- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
|
||||
-- plus the front edge -- standing against the wall band, so its box
|
||||
-- runs z 16..25 of a 32-deep plot.
|
||||
local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
|
||||
local dz0 = t.desk.z or 0
|
||||
local dz1 = dz0 + deskD - 1
|
||||
local deskG = b1 + 1
|
||||
-- ...and the desk's COLUMNS (`x`), when the grid is wider than the
|
||||
-- desk: the healing machine's grid carries its flanking hoses and
|
||||
-- keyboard, and the cabinet is only the middle 16 columns.
|
||||
local dx0 = t.desk.x and t.desk.x[1] or 0
|
||||
local dx1 = t.desk.x and t.desk.x[2] or W - 1
|
||||
|
||||
-- The WALL element: the band the machine backs onto, whose tiles this
|
||||
-- grid claims. The drawing shows it only as the stripe background
|
||||
-- around the tower (the same standing as the potted plants' floor),
|
||||
-- so the block cycles the drawing's own stripe unit -- real pixels of
|
||||
-- column `x`, rows `cycle` -- at wall-band height over the back plot,
|
||||
-- exactly what the neighbouring cells' `wall` pins render.
|
||||
if t.wall then
|
||||
local wl = t.wall
|
||||
local c0, c1 = wl.cycle[1], wl.cycle[2]
|
||||
local cn = c1 - c0 + 1
|
||||
local wx = wl.x or 0
|
||||
for y = 0, wl.h - 1 do
|
||||
Budget.tick()
|
||||
local sy = c0 + (wl.h - 1 - y) % cn
|
||||
for sx = 0, W - 1 do
|
||||
for z = 0, wl.depthPx - 1 do
|
||||
put(sx, y, z, sy * W + wx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the base band, extruded exactly like every lab table's
|
||||
for sy = b0, b1 do
|
||||
Budget.tick()
|
||||
local y = deskG - 1 - sy
|
||||
for sx = dx0, dx1 do
|
||||
if inside[sy * W + sx] then
|
||||
local ix = interiorAt(sx, sy, dx0, dx1)
|
||||
for z = dz0, dz1 do
|
||||
local px = (z == dz0 or z == dz1) and sx or ix
|
||||
put(sx, y, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for i in pairs(pr.recess) do
|
||||
local sy = math.floor(i / W)
|
||||
local sx = i % W
|
||||
if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then
|
||||
vox[key(sx, deskG - 1 - sy, dz1)] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- the slab: fascia rows wrap every side
|
||||
for sy = f0, f1 do
|
||||
Budget.tick()
|
||||
local y = plane - 1 - (sy - f0)
|
||||
for sx = dx0, dx1 do
|
||||
for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
|
||||
end
|
||||
end
|
||||
|
||||
if t.desk.top then
|
||||
-- The lid wears the desk's own drawn top band -- the drawing DOES
|
||||
-- paint this tabletop (the healing machine's white top face with
|
||||
-- its lit west and shaded east strips), so nothing is synthesized
|
||||
-- where it is visible: band rows map back-to-front, the first
|
||||
-- fascia row is the drawn front-top edge, same rule as an upright
|
||||
-- part's lid. Where a part's drawing occludes the band (the monitor
|
||||
-- standing on it), the lid continues the nearest strip BESIDE the
|
||||
-- part -- still the drawing's own pixels, the same sibling-pattern
|
||||
-- rule every synthesized lid follows.
|
||||
local tr0, tr1 = t.desk.top[1], t.desk.top[2]
|
||||
for z = dz0, dz1 do
|
||||
Budget.tick()
|
||||
local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
|
||||
for sx = dx0, dx1 do
|
||||
local px = sx
|
||||
for _, p in ipairs(t.parts) do
|
||||
local px0, px1 = p.x[1], p.x[2]
|
||||
local r0, r1
|
||||
if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then
|
||||
r0, r1 = p.rows[1], p.rows[2]
|
||||
else
|
||||
r0, r1 = p.top[1], p.facade[2]
|
||||
end
|
||||
if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
|
||||
px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
|
||||
px = math.max(dx0, math.min(dx1, px))
|
||||
break
|
||||
end
|
||||
end
|
||||
put(sx, plane - 1, z, sy * W + px)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- the lid continues the sibling tables' top -- black rim, white
|
||||
-- highlight courses along the north and west, grey field
|
||||
local field = t.desk.lid == "white" and WHITE or GREY
|
||||
for sx = dx0, dx1 do
|
||||
for z = dz0, dz1 do
|
||||
local shade = field
|
||||
if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then
|
||||
shade = BLACK
|
||||
elseif sx == dx0 + 1 or z == dz0 + 1 then
|
||||
shade = WHITE
|
||||
end
|
||||
put(sx, plane - 1, z, pr.shadeTexel[shade])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if plane > ytop then ytop = plane end
|
||||
buildParts(plane)
|
||||
|
||||
return { at = function(x, y, z)
|
||||
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
|
||||
return vox[key(x, y, z)]
|
||||
end,
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||
-- are trimmed to its underside so nothing pokes through the surface.
|
||||
local function model(sp, pr, t)
|
||||
if t.parts then return deskSetModel(sp, pr, t) end
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
local top, ytop = pr.top, pr.ytop
|
||||
local top, ytop, ground = pr.top, pr.ytop, pr.ground
|
||||
|
||||
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
|
||||
-- columns undrawn in the roof band; they carry no roof at all, and the
|
||||
@@ -367,16 +956,18 @@ local function model(sp, pr, t)
|
||||
|
||||
-- the awning: the band juts two voxels past the walls, front and back
|
||||
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
|
||||
local sy = H - 1 - y
|
||||
local sy = ground - 1 - y
|
||||
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
||||
return sy * W + x
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the facade, extruded straight back over the footprint
|
||||
-- the facade, extruded straight back over the footprint. Rows map
|
||||
-- against the measured ground line, not the grid's last row: the two
|
||||
-- differ only for furniture standing on open floor (see measure).
|
||||
if z < 0 or z >= D then return nil end
|
||||
local sy = H - 1 - y
|
||||
local sy = ground - 1 - y
|
||||
local i = sy * W + x
|
||||
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
||||
-- the drawing's last row is the ground the building stands on, so
|
||||
@@ -464,7 +1055,8 @@ local function emit(m, sp, atlasW, atlasH)
|
||||
local function runX(y, z, dx, dy, dz, x)
|
||||
local i0 = ci(x, y, z)
|
||||
local strip, n = nil, 1
|
||||
while true do
|
||||
local cap = runCap(x)
|
||||
while n < cap do
|
||||
local nx = x + n
|
||||
local i = ci(nx, y, z)
|
||||
if not i or ci(nx + dx, y + dy, z + dz) then break end
|
||||
@@ -556,8 +1148,8 @@ local function emit(m, sp, atlasW, atlasH)
|
||||
while z <= zmax do
|
||||
local i = ci(x, y, z)
|
||||
if i and not ci(x + d, y, z) then
|
||||
local n = 1
|
||||
while z + n <= zmax do
|
||||
local n, cap = 1, runCap(z)
|
||||
while n < cap and z + n <= zmax do
|
||||
local j = ci(x, y, z + n)
|
||||
if j ~= i or ci(x + d, y, z + n) then break end
|
||||
n = n + 1
|
||||
@@ -666,7 +1258,7 @@ function Buildings.build(S, map, data, perRow)
|
||||
end
|
||||
built = models[key]
|
||||
end
|
||||
Buildings.stamp(S, map, built, tx, ty, bw, bh)
|
||||
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -676,9 +1268,24 @@ end
|
||||
|
||||
-- One placement: claim its tiles (so the detector leaves them alone and
|
||||
-- the mesher paints ground under them) and copy the model into place.
|
||||
function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
local shape = { class = "building", h = 0, art = "building",
|
||||
flat = false, authored = true }
|
||||
--
|
||||
-- Two template fields alter what a claim means, for a drawing that
|
||||
-- carries a STANDEE on its surface (Red's potted plant on the dining
|
||||
-- table). `keep` names tile ids the stamp must NOT claim: their authored
|
||||
-- pins stay live, so the standee scan still stands the object exactly as
|
||||
-- it always did. `support` is the model's top plane in voxels: the claim
|
||||
-- shape carries it as its height, which is what tells that scan the
|
||||
-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
|
||||
-- a building claim with height as a full model (skip, never a second
|
||||
-- box; see its support branches).
|
||||
function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
|
||||
local shape = { class = "building", h = (t and t.support) or 0,
|
||||
art = "building", flat = false, authored = true }
|
||||
local keep = nil
|
||||
if t and t.keep then
|
||||
keep = {}
|
||||
for _, id in ipairs(t.keep) do keep[id] = true end
|
||||
end
|
||||
|
||||
-- the ground the building stands on: the commonest flat tile around its
|
||||
-- feet, so a house on a path keeps its path
|
||||
@@ -704,9 +1311,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
for r = 0, bh - 1 do
|
||||
for c = 0, bw - 1 do
|
||||
local k = keyOf(tx + c, ty + r)
|
||||
S.shapeAt[k] = shape
|
||||
S.skip[k] = true
|
||||
S.ground[k] = best or false
|
||||
if keep and keep[S.tileAt[k]] then
|
||||
-- unclaimed by request: the tile keeps its pin (the plant's
|
||||
-- cutout pool) and the standee scan finds it there. Only the
|
||||
-- ground is set now, so the scan's own claim of these tiles has
|
||||
-- the building's floor to paint when no flat tile touches a
|
||||
-- cluster ringed by its own furniture.
|
||||
S.ground[k] = best or false
|
||||
else
|
||||
S.shapeAt[k] = shape
|
||||
S.skip[k] = true
|
||||
S.ground[k] = best or false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+145
-17
@@ -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),
|
||||
@@ -427,6 +439,18 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
s = nil
|
||||
end
|
||||
|
||||
-- Under the TREES fill the border wall is MODELLED or it is not there
|
||||
-- (see Structures' hullRingOnly): a ring cell nothing claimed would
|
||||
-- be a flat-topped box standing beside carved trunks, which reads as
|
||||
-- a painted-on plateau rather than forest. Structures already stops
|
||||
-- the ring at the carve distance; this catches the odd cell inside it
|
||||
-- that the 2x2 grouping could not take -- a canopy whose partners
|
||||
-- fall outside the shortened ring is left unclaimed, and one strip of
|
||||
-- boxes along an edge is the whole artefact this avoids.
|
||||
if not inBody and S.hideBareRing and not S.skip[k] then
|
||||
s = nil
|
||||
end
|
||||
|
||||
if s and S.skip[k] then
|
||||
-- an object stands here; paint its synthesized ground and let the
|
||||
-- prebuilt prism quads (appended below) carry the art
|
||||
@@ -546,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
|
||||
@@ -752,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)
|
||||
@@ -801,6 +847,43 @@ local function buildFlowerMesh(map)
|
||||
return quadsMesh(Structures.forMap(map).flowerQuads)
|
||||
end
|
||||
|
||||
-- Authored FIGURES (a person drawn into furniture) as one mesh each, in
|
||||
-- the card's own local space -- because each one is placed by its own
|
||||
-- matrix at draw time, leaned back by the camera pitch exactly like a
|
||||
-- character card (VoxelScene). A figure baked into the terrain mesh could
|
||||
-- not lean, and a shared mesh could not carry per-figure placement.
|
||||
--
|
||||
-- A list, not a mesh: `{ mesh, wx, wz, y, w }` per figure. Maps have one
|
||||
-- or none, so the loop that draws them is shorter than the terrain's.
|
||||
-- `w` is the card's own width in its local space (its quads start at
|
||||
-- x = 0), measured here because the first-person pass yaws a card about
|
||||
-- its middle -- a card yawed about its left edge swings off its seat.
|
||||
local function buildFigureMeshes(map)
|
||||
local out = {}
|
||||
for _, f in ipairs(Structures.forMap(map).figures or {}) do
|
||||
local mesh = quadsMesh(f.quads)
|
||||
if mesh then
|
||||
local w = 0
|
||||
for _, q in ipairs(f.quads) do
|
||||
for c = 1, 4 do
|
||||
local x = q[c] and q[c][1]
|
||||
if x and x > w then w = x end
|
||||
end
|
||||
end
|
||||
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y, w = w }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Figure lists hold their meshes one level down, so the generic slot
|
||||
-- release cannot reach them.
|
||||
local function releaseFigures(list)
|
||||
for _, f in ipairs(type(list) == "table" and list or {}) do
|
||||
if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Replace a cached slot, releasing whatever mesh it held.
|
||||
local function swapSlot(c, slot, mesh)
|
||||
local old = c[slot]
|
||||
@@ -819,12 +902,23 @@ local function entry(id)
|
||||
return c
|
||||
end
|
||||
|
||||
-- The water surface that came out of a terrain slot's own build. Kept
|
||||
-- beside it rather than in a slot of its own because the two are ONE
|
||||
-- answer: a full mesh drawn beside a body build's water would draw the
|
||||
-- ring's ponds twice and miss the body's own.
|
||||
local function waterSlot(slot)
|
||||
return slot .. "Water"
|
||||
end
|
||||
|
||||
local function releaseEntry(c)
|
||||
for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do
|
||||
for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater",
|
||||
"grass", "flowers" }) do
|
||||
local mesh = c[slot]
|
||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||
c[slot] = nil
|
||||
end
|
||||
releaseFigures(c.figures)
|
||||
c.figures = nil
|
||||
c.stale = nil
|
||||
end
|
||||
|
||||
@@ -863,28 +957,37 @@ end
|
||||
local function runJob(job)
|
||||
local map = job.map
|
||||
local c = entry(job.id)
|
||||
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
|
||||
if c.grass == nil or c.flowers == nil or c.figures == nil
|
||||
or (c.stale and c.stale.aux) then
|
||||
local okG, grass = pcall(buildGrassMesh, map)
|
||||
local okF, flowers = pcall(buildFlowerMesh, map)
|
||||
local okX, figures = pcall(buildFigureMeshes, map)
|
||||
if (gen[job.id] or 0) ~= job.gen then
|
||||
if okG and grass and grass.release then pcall(grass.release, grass) end
|
||||
if okF and flowers and flowers.release then
|
||||
pcall(flowers.release, flowers)
|
||||
end
|
||||
if okX then releaseFigures(figures) end
|
||||
return
|
||||
end
|
||||
swapSlot(c, "grass", (okG and grass) or false)
|
||||
swapSlot(c, "flowers", (okF and flowers) or false)
|
||||
releaseFigures(c.figures)
|
||||
c.figures = (okX and figures) or false
|
||||
if c.stale then c.stale.aux = nil end
|
||||
end
|
||||
local sink = newSink()
|
||||
runGeometry(map, job.slot == "body", job.masks, sink)
|
||||
local waterSink = newSink()
|
||||
runGeometry(map, job.slot == "body", job.masks, sink, waterSink)
|
||||
local mesh = sink.finish()
|
||||
local water = waterSink.finish()
|
||||
if (gen[job.id] or 0) ~= job.gen then
|
||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||
if water and water.release then pcall(water.release, water) end
|
||||
return
|
||||
end
|
||||
swapSlot(c, job.slot, mesh or false)
|
||||
swapSlot(c, waterSlot(job.slot), water or false)
|
||||
if c.stale then
|
||||
c.stale[job.slot] = nil
|
||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||
@@ -986,12 +1089,14 @@ function ChunkMesher.get(map, bodyOnly, masks)
|
||||
if c.stale then c.stale.aux = nil end
|
||||
end
|
||||
if c[slot] == nil or (c.stale and c.stale[slot]) then
|
||||
local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
|
||||
local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks,
|
||||
true)
|
||||
if not ok then
|
||||
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
||||
.. ": " .. tostring(mesh))
|
||||
end
|
||||
swapSlot(c, slot, (ok and mesh) or false)
|
||||
swapSlot(c, waterSlot(slot), (ok and water) or false)
|
||||
if c.stale then
|
||||
c.stale[slot] = nil
|
||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||
@@ -1012,6 +1117,21 @@ function ChunkMesher.peek(map, bodyOnly)
|
||||
return mesh or nil
|
||||
end
|
||||
|
||||
-- A slot's terrain mesh AND the water surface lifted out of it, as one
|
||||
-- answer. Never builds, like peek.
|
||||
--
|
||||
-- Both or neither, always from the SAME slot: the water was cut out of that
|
||||
-- exact geometry, so pairing a full mesh with a body build's water would
|
||||
-- draw the border ring's ponds twice and leave the body's as holes. Callers
|
||||
-- that fall back from one variant to the other fall back through this, so
|
||||
-- there is nowhere for the two to be chosen separately.
|
||||
function ChunkMesher.pair(map, bodyOnly)
|
||||
local c = cache[map.id]
|
||||
if not c then return nil, nil end
|
||||
local slot = bodyOnly and "body" or "full"
|
||||
return c[slot] or nil, c[waterSlot(slot)] or nil
|
||||
end
|
||||
|
||||
function ChunkMesher.grass(map)
|
||||
local c = cache[map.id]
|
||||
return c and c.grass or nil
|
||||
@@ -1022,6 +1142,14 @@ function ChunkMesher.flowers(map)
|
||||
return c and c.flowers or nil
|
||||
end
|
||||
|
||||
-- Authored figures as `{ mesh, wx, wz, y, w }` records -- each placed by
|
||||
-- its own leaning matrix at draw time, so they cannot share one mesh.
|
||||
function ChunkMesher.figures(map)
|
||||
local c = cache[map.id]
|
||||
local list = c and c.figures
|
||||
return (type(list) == "table") and list or nil
|
||||
end
|
||||
|
||||
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
|
||||
-- replacements cook, and each slot swaps as its build lands. This is
|
||||
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
-- Voxel world mode: the day/night cycle -- one clock, and everything the
|
||||
-- frame asks it.
|
||||
--
|
||||
-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
|
||||
-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
|
||||
-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
|
||||
-- the player left is where the cycle picks up. Everything below is a pure
|
||||
-- function of the clock, so the pinned settings and the running cycle can
|
||||
-- never drift apart: DUSK is simply the cycle stopped at sunset.
|
||||
--
|
||||
-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
|
||||
-- hanging in the southeast about 45 degrees up. That is the DAY setting and
|
||||
-- the default, so a player who never touches the row sees the mod they
|
||||
-- already had. From there the arc swings NORTH at both ends -- rising 70
|
||||
-- degrees north of east, setting the mirror of that -- because the camera
|
||||
-- looks north and the northern sky is the only sky it ever frames: a sun
|
||||
-- that rose due east would light the world for ten minutes without once
|
||||
-- being seen. Swung north, the disc stands in frame through dawn and dusk
|
||||
-- (the hours worth looking at) and passes overhead-behind-the-camera
|
||||
-- through midday, which is where a noon sun belongs.
|
||||
--
|
||||
-- THE MOON arcs entirely through the northern sky -- rising northeast, due
|
||||
-- north at mid-night, setting northwest -- so it hangs over the diorama all
|
||||
-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
|
||||
-- softly south, away from it, at about two-thirds the sun's weight.
|
||||
--
|
||||
-- SHADOWS are the shear the light throws: direction opposite the body's
|
||||
-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
|
||||
-- a long shadow, not an infinite one), strength fading to nothing over the
|
||||
-- last twelve degrees before the horizon so the handoff between sun and
|
||||
-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
|
||||
-- deliberately stays the noon bake: it is a subtle angle term baked into
|
||||
-- every mesh, and rebaking the world's geometry per phase buys less than
|
||||
-- the cast shadows, the sky and the tint already say.
|
||||
--
|
||||
-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
|
||||
-- a cave at midnight is exactly as dark as a cave at noon, which is what a
|
||||
-- room with no windows looks like. Map.isOutdoor is the same test the sky
|
||||
-- already rests on; the caller passes its answer in (applyRig/tint).
|
||||
--
|
||||
-- Persistence: the running cycle's clock is written into the mod's own
|
||||
-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
|
||||
-- engine's save.writing event, and read back on save.loaded/created. A save
|
||||
-- with no clock in it starts at noon.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local DayNight = {}
|
||||
|
||||
-- ------- the dial
|
||||
|
||||
DayNight.CYCLE = 1200 -- seconds around the whole dial
|
||||
DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest
|
||||
DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight
|
||||
|
||||
-- where the pinned settings stop the clock
|
||||
DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
|
||||
|
||||
DayNight.KEY = "daytime"
|
||||
DayNight.LABEL = "DAYTIME"
|
||||
|
||||
-- "sync" first: an unset or unreadable value follows the machine's own
|
||||
-- clock, per the row's contract (ModSetting values[1] is the default) --
|
||||
-- and forceSync below reaches for it by the same position.
|
||||
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
|
||||
{ "sync", "day", "night", "dusk",
|
||||
"dawn", "cycle" },
|
||||
{ "SYNC", "DAY", "NIGHT", "DUSK",
|
||||
"DAWN", "CYCLE" })
|
||||
|
||||
-- The one writer for the FULL pin. While VOXEL sits on FULL the DAYTIME
|
||||
-- row is off the menu with the rest of the rows the preset owns, and the
|
||||
-- value is held HERE at SYNC -- the diorama preset's sky follows the clock
|
||||
-- on the wall, whatever was chosen before. Called from every path that can
|
||||
-- arrive at or act under FULL (main.lua: the preset itself, the rows hook,
|
||||
-- the manager's options_changed), mirroring OverworldBattle.forceOG.
|
||||
function DayNight.forceSync(game)
|
||||
if DayNight.setting:get() ~= "sync" then
|
||||
DayNight.setting:setIndex(1, game)
|
||||
end
|
||||
end
|
||||
|
||||
DayNight.clock = DayNight.T.day -- the running cycle's own position
|
||||
|
||||
-- ------- the two arcs
|
||||
--
|
||||
-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
|
||||
-- south), elevations in degrees up from the ground plane.
|
||||
|
||||
-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
|
||||
-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
|
||||
local NOON_KX, NOON_KZ = -0.85, -0.55
|
||||
local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
|
||||
local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
|
||||
+ NOON_KZ * NOON_KZ)))
|
||||
|
||||
local TH_RISE, TH_SET = -70, 250 -- north of east / north of west
|
||||
local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
|
||||
local EL_MOON = 40
|
||||
|
||||
DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height
|
||||
DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight
|
||||
DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press
|
||||
DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set
|
||||
|
||||
-- disc PLACEMENT only: the true elevation would put the noon sun far above
|
||||
-- any frame, so the arc the discs ride is squashed toward the horizon. The
|
||||
-- shadows always use the true elevation.
|
||||
DayNight.ELEV_SQUASH = 0.14
|
||||
|
||||
-- three-point arc: a at s=0, b at s=0.5, c at s=1
|
||||
local function arc(a, b, c, s)
|
||||
if s < 0.5 then return a + (b - a) * 2 * s end
|
||||
return b + (c - b) * (2 * s - 1)
|
||||
end
|
||||
|
||||
-- The body lighting the world at clock `t`: bearing and elevation in
|
||||
-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
|
||||
-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
|
||||
-- not the moon rising.
|
||||
function DayNight.bodyAt(t)
|
||||
t = t % DayNight.CYCLE
|
||||
if t <= DayNight.DAY_LEN then
|
||||
local s = t / DayNight.DAY_LEN
|
||||
return arc(TH_RISE, TH_NOON, TH_SET, s),
|
||||
EL_NOON * math.sin(math.pi * s), false
|
||||
end
|
||||
local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
|
||||
return arc(TH_MRISE, TH_MMID, TH_MSET, s),
|
||||
EL_MOON * math.sin(math.pi * s), true
|
||||
end
|
||||
|
||||
-- The shadow shear that body throws: drift per pixel of height, opposite
|
||||
-- the bearing, cot(elevation) long, clamped.
|
||||
function DayNight.shearAt(t)
|
||||
local th, el, moon = DayNight.bodyAt(t)
|
||||
if el < 0.5 then el = 0.5 end
|
||||
local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
|
||||
return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
|
||||
end
|
||||
|
||||
-- How much shadow the light can press right now, 0..1 of the body's own
|
||||
-- weight: full up high, gone at the horizon, so sunset hands off to
|
||||
-- moonrise through a soft shadowless gap instead of snapping.
|
||||
function DayNight.strengthAt(t)
|
||||
local _, el = DayNight.bodyAt(t)
|
||||
local s = el / DayNight.FADE_DEG
|
||||
if s < 0 then return 0 end
|
||||
return s < 1 and s or 1
|
||||
end
|
||||
|
||||
-- ------- the palettes
|
||||
--
|
||||
-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
|
||||
-- reads. Six bands, not four: twilight is the whole show here, and six rungs
|
||||
-- of it is what keeps a sunset reading as a gradient rather than as stripes.
|
||||
-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
|
||||
-- after blending, which re-quantises onto it.
|
||||
-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
|
||||
-- through. Day's blue horizon and dusk's gold one are near-complements, and
|
||||
-- a straight lerp between complements bottoms out in grey: mid-transition
|
||||
-- the whole sky went the colour of dishwater, and gold-to-navy did the same
|
||||
-- on the far side of sunset. So the evening bends through a golden hour
|
||||
-- (horizon warming, zenith still blue -- late afternoon), and both edges of
|
||||
-- the night bend through a violet civil twilight (rose horizon under a
|
||||
-- violet sky -- the real colour of that half hour).
|
||||
DayNight.PALETTES = {
|
||||
day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
|
||||
{ 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
|
||||
golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
|
||||
{ 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
|
||||
dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
|
||||
{ 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
|
||||
dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
|
||||
{ 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
|
||||
violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
|
||||
{ 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
|
||||
night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
|
||||
{ 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
|
||||
}
|
||||
|
||||
-- what the world's own colours are multiplied by, per phase (0..255)
|
||||
DayNight.TINTS = {
|
||||
day = { 255, 255, 255 },
|
||||
golden = { 255, 232, 208 },
|
||||
dawn = { 255, 216, 192 },
|
||||
dusk = { 255, 192, 168 },
|
||||
violet = { 184, 160, 200 },
|
||||
night = { 120, 136, 192 },
|
||||
}
|
||||
|
||||
-- the twilight glow around the low sun, and the discs' own four-shade
|
||||
-- palettes (lightest first, so a display mode transforms them like any
|
||||
-- other palette)
|
||||
DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
|
||||
DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
|
||||
{ 248, 144, 80 }, { 216, 96, 64 } }
|
||||
DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
|
||||
{ 168, 184, 208 }, { 120, 136, 168 } }
|
||||
|
||||
-- The dial as keyframes: a repeated name is a plateau, a change is a
|
||||
-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
|
||||
-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
|
||||
-- waypoint rather than straight across the grey between blue and gold. The
|
||||
-- morning side needs no waypoint of its own: dawn's pinks into day's blues
|
||||
-- share a family and blend clean.
|
||||
local DIAL
|
||||
local function dial()
|
||||
if DIAL then return DIAL end
|
||||
local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
|
||||
DIAL = {
|
||||
{ 0, "dawn" }, { B, "day" },
|
||||
{ D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
|
||||
{ D + B / 2, "violet" }, { D + B, "night" },
|
||||
{ C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
|
||||
}
|
||||
return DIAL
|
||||
end
|
||||
|
||||
-- Phase weights at clock `t`, off the dial above.
|
||||
function DayNight.mix(t)
|
||||
t = t % DayNight.CYCLE
|
||||
local d = dial()
|
||||
for i = 1, #d - 1 do
|
||||
local a, b = d[i], d[i + 1]
|
||||
if t >= a[1] and t < b[1] then
|
||||
if a[2] == b[2] then return { [a[2]] = 1 } end
|
||||
local u = (t - a[1]) / (b[1] - a[1])
|
||||
return { [a[2]] = 1 - u, [b[2]] = u }
|
||||
end
|
||||
end
|
||||
return { dawn = 1 }
|
||||
end
|
||||
|
||||
-- back onto the 5-bit lattice after any blend
|
||||
local function q8(v)
|
||||
v = math.floor(v / 8 + 0.5) * 8
|
||||
if v < 0 then return 0 end
|
||||
return v > 248 and 248 or v
|
||||
end
|
||||
|
||||
local function blend3(key, mix, fallback)
|
||||
local r, g, b = 0, 0, 0
|
||||
for name, w in pairs(mix) do
|
||||
local c = key[name] or fallback
|
||||
r = r + c[1] * w
|
||||
g = g + c[2] * w
|
||||
b = b + c[3] * w
|
||||
end
|
||||
return { q8(r), q8(g), q8(b) }
|
||||
end
|
||||
|
||||
-- The sky palette for clock `t`, blended between the phase palettes and
|
||||
-- re-quantised to the lattice. Memoised per whole second: the answer only
|
||||
-- moves as the cycle runs, and the cycle moves it slowly.
|
||||
local palCache = { key = nil, pal = nil }
|
||||
|
||||
function DayNight.palette(t)
|
||||
t = t or DayNight.time()
|
||||
local key = math.floor(t % DayNight.CYCLE)
|
||||
if palCache.key == key then return palCache.pal end
|
||||
local mix = DayNight.mix(t)
|
||||
local pal = {}
|
||||
for i = 1, #DayNight.PALETTES.day do
|
||||
local r, g, b = 0, 0, 0
|
||||
for name, w in pairs(mix) do
|
||||
local c = DayNight.PALETTES[name][i]
|
||||
r = r + c[1] * w
|
||||
g = g + c[2] * w
|
||||
b = b + c[3] * w
|
||||
end
|
||||
pal[i] = { q8(r), q8(g), q8(b) }
|
||||
end
|
||||
palCache.key, palCache.pal = key, pal
|
||||
return pal
|
||||
end
|
||||
|
||||
-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
|
||||
-- caller answers for where it is standing (see the header).
|
||||
local tintCache = { key = nil, tint = nil }
|
||||
local NEUTRAL = { 1, 1, 1 }
|
||||
|
||||
function DayNight.tint(outdoor, t)
|
||||
if not outdoor then return NEUTRAL end
|
||||
t = t or DayNight.time()
|
||||
local key = math.floor(t % DayNight.CYCLE)
|
||||
if tintCache.key ~= key then
|
||||
-- NOT re-quantised: this is a light level the shader multiplies by, not
|
||||
-- a palette colour, and the lattice's 248 ceiling would make even noon
|
||||
-- fractionally dim
|
||||
local mix = DayNight.mix(t)
|
||||
local r, g, b = 0, 0, 0
|
||||
for name, w in pairs(mix) do
|
||||
local c = DayNight.TINTS[name] or DayNight.TINTS.day
|
||||
r = r + c[1] * w
|
||||
g = g + c[2] * w
|
||||
b = b + c[3] * w
|
||||
end
|
||||
tintCache.key = key
|
||||
tintCache.tint = { r / 255, g / 255, b / 255 }
|
||||
end
|
||||
return tintCache.tint
|
||||
end
|
||||
|
||||
-- The twilight glow: how strongly (0..1) and in what colour the sky warms
|
||||
-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
|
||||
function DayNight.glow(t)
|
||||
t = t or DayNight.time()
|
||||
local _, _, moon = DayNight.bodyAt(t)
|
||||
if moon then return 0, nil end
|
||||
local mix = DayNight.mix(t)
|
||||
local amt = (mix.dawn or 0) + (mix.dusk or 0)
|
||||
if amt <= 0 then return 0, nil end
|
||||
return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
|
||||
end
|
||||
|
||||
-- ------- the clock itself
|
||||
|
||||
local lastMode = nil
|
||||
|
||||
local function mode()
|
||||
return DayNight.setting:get() or "day"
|
||||
end
|
||||
|
||||
-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
|
||||
-- fraction. A named seam rather than a bare os.date call, so the suite can
|
||||
-- hand it a fixed hour.
|
||||
function DayNight.hours()
|
||||
local d = os.date("*t")
|
||||
return d.hour + d.min / 60 + d.sec / 3600
|
||||
end
|
||||
|
||||
-- SYNC: the machine's own time of day laid onto the dial. Local noon is
|
||||
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
|
||||
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
|
||||
-- falls when the player's does.
|
||||
function DayNight.syncTime()
|
||||
return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
|
||||
end
|
||||
|
||||
-- The effective time: the pin, the running clock under CYCLE, or the wall
|
||||
-- clock under SYNC.
|
||||
function DayNight.time()
|
||||
local m = mode()
|
||||
if m == "cycle" then return DayNight.clock end
|
||||
if m == "sync" then return DayNight.syncTime() end
|
||||
return DayNight.T[m] or DayNight.T.day
|
||||
end
|
||||
|
||||
-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
|
||||
-- (which ticks through battles and menus too, so night falls during a long
|
||||
-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
|
||||
-- the pin the player was just looking at: DUSK then CYCLE rolls on into
|
||||
-- night rather than teleporting the sky.
|
||||
function DayNight.update(dt)
|
||||
local m = mode()
|
||||
if m ~= lastMode then
|
||||
if m == "cycle" then
|
||||
-- from a pin, its time; from SYNC, wherever the real sky already was
|
||||
DayNight.clock = DayNight.T[lastMode]
|
||||
or (lastMode == "sync" and DayNight.syncTime())
|
||||
or DayNight.clock
|
||||
end
|
||||
lastMode = m
|
||||
end
|
||||
if m == "cycle" and dt and dt > 0 then
|
||||
DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
|
||||
end
|
||||
end
|
||||
|
||||
-- The clock the RIG runs on: quantised, so the shadow map redraws a few
|
||||
-- times a minute as the sun crawls rather than every frame.
|
||||
DayNight.STEP = 2
|
||||
|
||||
function DayNight.rigTime()
|
||||
local t = DayNight.time()
|
||||
return math.floor(t / DayNight.STEP) * DayNight.STEP
|
||||
end
|
||||
|
||||
-- ------- what the frame reads
|
||||
|
||||
-- Point the shared light rig at the clock -- or at noon, indoors. This
|
||||
-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
|
||||
-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
|
||||
-- sunDark uniform), so no draw path changes to follow the sun; they follow
|
||||
-- the rig, and the rig follows the clock.
|
||||
function DayNight.applyRig(outdoor)
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local t = outdoor and DayNight.rigTime() or DayNight.T.day
|
||||
local kx, kz, moon = DayNight.shearAt(t)
|
||||
ShadowMap.KX, ShadowMap.KZ = kx, kz
|
||||
Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
|
||||
local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
|
||||
Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
|
||||
return t
|
||||
end
|
||||
|
||||
-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
|
||||
-- caller that sets its own alpha (the battle arena) and should still lose
|
||||
-- its shadows to a sunset.
|
||||
function DayNight.shadowScale(outdoor, t)
|
||||
if not outdoor then return 1 end
|
||||
t = t or DayNight.rigTime()
|
||||
local _, _, moon = DayNight.bodyAt(t)
|
||||
local s = DayNight.strengthAt(t)
|
||||
return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
|
||||
end
|
||||
|
||||
-- The disc to hang in the sky, or nil when the body is set or behind the
|
||||
-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
|
||||
-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
|
||||
-- is; the caller projects it through its own camera.
|
||||
function DayNight.body(t)
|
||||
t = t or DayNight.time()
|
||||
local th, el, moon = DayNight.bodyAt(t)
|
||||
if el < -2 then return nil end
|
||||
local e = math.rad(el * DayNight.ELEV_SQUASH)
|
||||
local b = math.rad(th)
|
||||
return {
|
||||
dx = math.cos(b) * math.cos(e),
|
||||
dy = math.sin(e),
|
||||
dz = math.sin(b) * math.cos(e),
|
||||
moon = moon,
|
||||
}
|
||||
end
|
||||
|
||||
-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun
|
||||
-- or moon to see, so the shadow rig stays the mod's fixed noon light,
|
||||
-- which is all that ever filtered through the leaves -- but not a sealed
|
||||
-- room either: night still FALLS in them. Of everything the clock does,
|
||||
-- exactly one thing reaches a canopy map: the hour's tint.
|
||||
DayNight.CANOPY = { VIRIDIAN_FOREST = true }
|
||||
|
||||
function DayNight.isCanopy(map)
|
||||
return (map and map.id and DayNight.CANOPY[map.id]) and true or false
|
||||
end
|
||||
|
||||
-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky.
|
||||
-- They come on through dusk (a lit window against a sunset is half the point
|
||||
-- of having either), burn all night, and are mostly out again by dawn:
|
||||
-- people wake before it is bright, they do not read at sunrise.
|
||||
local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 }
|
||||
|
||||
function DayNight.windowLight(t)
|
||||
local mix = DayNight.mix(t or DayNight.time())
|
||||
local lit = 0
|
||||
for name, w in pairs(mix) do
|
||||
lit = lit + (LAMPS[name] or 0) * w
|
||||
end
|
||||
return lit
|
||||
end
|
||||
|
||||
-- The period name for the engine's world.tod hook (map.palette ctx.tod,
|
||||
-- music.select): the dominant phase, in the vocabulary day/night mods use.
|
||||
local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
|
||||
violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
|
||||
|
||||
function DayNight.tod(t)
|
||||
local mix = DayNight.mix(t or DayNight.time())
|
||||
local best, bestW = "day", -1
|
||||
for name, w in pairs(mix) do
|
||||
if w > bestW then best, bestW = name, w end
|
||||
end
|
||||
return TOD[best] or "DAY"
|
||||
end
|
||||
|
||||
-- ------- persistence
|
||||
--
|
||||
-- The clock rides the SAVE SLOT, not the options file: what time it is in
|
||||
-- Kanto is a fact about that journey, like where the player is standing.
|
||||
-- mod.save is the loader's per-mod bucket in save.modData, which persists
|
||||
-- with the slot on its own -- writing the value is all there is to do.
|
||||
|
||||
DayNight.SAVE_KEY = "clock"
|
||||
|
||||
function DayNight.store()
|
||||
local saveApi = V.mod and V.mod.save
|
||||
if not (saveApi and saveApi.set) then return end
|
||||
pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
|
||||
end
|
||||
|
||||
function DayNight.restore()
|
||||
local saveApi = V.mod and V.mod.save
|
||||
local stored = nil
|
||||
if saveApi and saveApi.get then
|
||||
local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
|
||||
if ok then stored = got end
|
||||
end
|
||||
-- no time set: it is day (the requirement, verbatim)
|
||||
DayNight.clock = type(stored) == "number"
|
||||
and stored % DayNight.CYCLE or DayNight.T.day
|
||||
end
|
||||
|
||||
return DayNight
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
-- The hour's light on the FLAT world.
|
||||
--
|
||||
-- The clock already reaches everything the 3D pass draws: VoxelScene and
|
||||
-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
|
||||
-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
|
||||
-- mode off and none of that happens -- the tint is a uniform in a shader the
|
||||
-- flat tile path never runs -- so the same evening that fell on the diorama
|
||||
-- left the 2D world at permanent noon. One clock, two worlds, one of them
|
||||
-- ignoring it.
|
||||
--
|
||||
-- So the flat composite gets the same multiply, painted as one rectangle.
|
||||
--
|
||||
-- ------- WHERE, which is the only difficult part
|
||||
--
|
||||
-- Not on the world canvas. In a colorized mode that canvas is grayscale art
|
||||
-- and the blit that puts it on screen runs it through the palette shader,
|
||||
-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
|
||||
-- night blue over it first and every shade lands in the wrong bucket -- the
|
||||
-- world would not darken, it would change colour into whatever the palette
|
||||
-- said the wrong bucket was.
|
||||
--
|
||||
-- So it goes on AFTER that pass, on the composited world. And not after the
|
||||
-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
|
||||
-- the HUD are paper held up in front of the world rather than part of it --
|
||||
-- the same reason the tilt-shift blur is a worldPresent and not a present.
|
||||
--
|
||||
-- Which leaves one instant: between the world blit and the UI blit, inside
|
||||
-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
|
||||
-- hook for exactly this, only runs when a PIPELINE produced the world, which
|
||||
-- in flat mode is the one thing that did not happen. So endFrame is wrapped
|
||||
-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
|
||||
-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
|
||||
-- by identity rather than by counting or guessing.
|
||||
--
|
||||
-- The shader and scissor that call arrives under belong to the UI blit already
|
||||
-- in progress, so both are put aside for the rectangle and handed straight
|
||||
-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
|
||||
--
|
||||
-- ------- WHEN
|
||||
--
|
||||
-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
|
||||
-- load-bearing:
|
||||
--
|
||||
-- the flat path a pipeline that rendered the world already applied the
|
||||
-- tint inside its own shader; painting it again would apply
|
||||
-- the hour twice. worldOverride is exactly "a pipeline drew
|
||||
-- this frame".
|
||||
-- outdoors a room has no sky to take its light from, which is the
|
||||
-- same answer DayNight.tint gives on its own and the same
|
||||
-- one applyRig gives the sun.
|
||||
-- not neutral midday is a multiply by white. Skipped rather than drawn,
|
||||
-- so a game with the clock at DAY issues not one extra call.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local DayNight = V.require("DayNight")
|
||||
|
||||
local DayTint = {}
|
||||
|
||||
-- Below this the tint is close enough to white that the rectangle would not
|
||||
-- change a pixel, and the frame is left exactly as it was.
|
||||
DayTint.NEUTRAL = 0.999
|
||||
|
||||
local function outdoorNow()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not ok then return false end
|
||||
local ow = Game and Game.overworld
|
||||
local map = ow and ow.map
|
||||
if not map then return false end
|
||||
local okMap, Map = pcall(require, "src.world.Map")
|
||||
if not okMap then return false end
|
||||
local outdoor = map.def and Map.isOutdoor(map.def) or false
|
||||
-- a canopy floor takes the hour's colour and nothing else of it, exactly as
|
||||
-- it does in the 3D pass (BattleScene, VoxelScene)
|
||||
return outdoor or DayNight.isCanopy(map)
|
||||
end
|
||||
|
||||
-- The colour this frame's world should be multiplied by, or nil to leave the
|
||||
-- frame alone.
|
||||
function DayTint.forFrame(renderer)
|
||||
if not renderer then return nil end
|
||||
if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
|
||||
if not renderer.worldActive then return nil end -- no world on screen at all
|
||||
if not outdoorNow() then return nil end
|
||||
local tint = DayNight.tint(true)
|
||||
if not tint then return nil end
|
||||
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||
if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
|
||||
return nil
|
||||
end
|
||||
return r, g, b
|
||||
end
|
||||
|
||||
-- One rectangle over the window, multiplied into whatever is under it.
|
||||
--
|
||||
-- The whole window rather than the world's own rect, which is what the
|
||||
-- engine's warp fade does from the same place and for the same reason: the
|
||||
-- border fill, the letterbox bars and the world are all "the world" as far as
|
||||
-- the hour is concerned, and black multiplied by anything is still black.
|
||||
-- Every read of the graphics state is optional, because a headless driver
|
||||
-- ships some of these and not others -- the same reason TerrainAtlas reads the
|
||||
-- engine's seams guarded. What cannot be read cannot be put back either, and a
|
||||
-- missing accessor must cost the tint rather than the frame.
|
||||
local function saved(name, ...)
|
||||
local fn = love.graphics[name]
|
||||
if not fn then return nil end
|
||||
local ok, a, b, c, d = pcall(fn, ...)
|
||||
if not ok then return nil end
|
||||
return a, b, c, d
|
||||
end
|
||||
|
||||
function DayTint.paint(r, g, b)
|
||||
local gfx = love.graphics
|
||||
local shader = saved("getShader")
|
||||
local sx, sy, sw, sh = saved("getScissor")
|
||||
local blend, alpha = saved("getBlendMode")
|
||||
local pr, pg, pb, pa = saved("getColor")
|
||||
local w, h = gfx.getDimensions()
|
||||
|
||||
if gfx.setShader then gfx.setShader() end
|
||||
if gfx.setScissor then gfx.setScissor() end
|
||||
gfx.setBlendMode("multiply", "premultiplied")
|
||||
gfx.setColor(r, g, b, 1)
|
||||
gfx.rectangle("fill", 0, 0, w, h)
|
||||
|
||||
gfx.setBlendMode(blend or "alpha", alpha)
|
||||
gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
|
||||
if gfx.setScissor then
|
||||
if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
|
||||
end
|
||||
if shader and gfx.setShader then gfx.setShader(shader) end
|
||||
end
|
||||
|
||||
function DayTint.install()
|
||||
local Renderer = require("src.render.Renderer")
|
||||
if Renderer.dramaticShapeTintHook then return end
|
||||
local inner = Renderer.endFrame
|
||||
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
local r, g, b = DayTint.forFrame(self)
|
||||
if not r then return inner(self, zones, worldZones) end
|
||||
|
||||
local gfx = love.graphics
|
||||
local draw = gfx.draw
|
||||
local ui = self.canvas
|
||||
local painted = false
|
||||
gfx.draw = function(tex, ...)
|
||||
-- the UI canvas reaching the screen: the world is finished, the paper
|
||||
-- in front of it has not started. Restored FIRST so the rectangle's own
|
||||
-- drawing cannot re-enter this, and so a UI blit that draws one quad per
|
||||
-- SGB zone only triggers it once.
|
||||
if not painted and tex == ui then
|
||||
painted = true
|
||||
gfx.draw = draw
|
||||
DayTint.paint(r, g, b)
|
||||
end
|
||||
return draw(tex, ...)
|
||||
end
|
||||
|
||||
local ok, err = pcall(inner, self, zones, worldZones)
|
||||
gfx.draw = draw
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
Renderer.dramaticShapeTintHook = true
|
||||
end
|
||||
|
||||
return DayTint
|
||||
@@ -0,0 +1,713 @@
|
||||
-- Voxel world mode: the first-person camera -- the 1ST rung.
|
||||
--
|
||||
-- Every other rung is the same camera at a different pitch: an orbit over
|
||||
-- the view centre, described by one number. 1ST is a different rig
|
||||
-- entirely: the eye stands in the player's own head, the view direction is
|
||||
-- the player's to steer -- mouse, right stick or a touch drag -- and the
|
||||
-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle
|
||||
-- already proved out. Everything downstream of that seam -- the shader
|
||||
-- uniforms, project(), the sky's vanishing line, the water's lean -- reads
|
||||
-- eye and focus the same way it always has.
|
||||
--
|
||||
-- What this module owns:
|
||||
--
|
||||
-- the ATTITUDE yaw and pitch, fed by whichever look input speaks:
|
||||
-- relative mouse motion, the right stick's rate, or a
|
||||
-- touch dragged across open screen. All three drive the
|
||||
-- same two numbers, so they compose instead of fighting.
|
||||
--
|
||||
-- the BLEND easing between the orbit and the head. Stepping onto
|
||||
-- the rung dives the camera from wherever the orbit was
|
||||
-- into the player's eyes over half a second; stepping off
|
||||
-- flies it back out. Mid-blend the rig is a straight lerp
|
||||
-- of the two cameras -- eye, focus, fov, up -- through
|
||||
-- the same placed-camera record.
|
||||
--
|
||||
-- the MOVE INTENT the analog vector FreeMove walks the player by,
|
||||
-- gathered here because it is made of the same devices:
|
||||
-- the left stick's raw axes, the touch d-pad's true
|
||||
-- deflection, or the held keys, rotated by this camera's
|
||||
-- yaw so "forward" means "where I am looking".
|
||||
--
|
||||
-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the
|
||||
-- collision walk and the grid the game logic still lives on), and the
|
||||
-- billboard math that faces cards at this eye (VoxelScene, which owns
|
||||
-- every other card matrix too).
|
||||
--
|
||||
-- Everything the module reaches -- the mouse's relative mode, the wrapped
|
||||
-- love handlers, the touch overlay's hit test -- is pcall-guarded the same
|
||||
-- way the 3D pass is: headless runs and drivers without a mouse simply
|
||||
-- never see the input, and the rung falls back to holding the 75-degree
|
||||
-- orbit.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
|
||||
local FirstPerson = {}
|
||||
|
||||
-- ------- the rig's numbers
|
||||
--
|
||||
-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head,
|
||||
-- not the hat tip -- above the same ground-plus-lift the character card
|
||||
-- stands on, so surfing bobs and ledge hops carry the view with them.
|
||||
--
|
||||
-- FOV is wider than the diorama's ~53 degrees: inside the world, the
|
||||
-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter
|
||||
-- middle ground.
|
||||
--
|
||||
-- FOCUS_DIST is short on purpose: the placed-camera branch derives its
|
||||
-- near plane from |eye - focus| (dist * 0.05), and the eye walks within
|
||||
-- 2-3 world pixels of a wall face when sliding along it -- a far focus
|
||||
-- would push the near plane through the wall and clip a hole in it.
|
||||
FirstPerson.EYE_HEIGHT = 13
|
||||
FirstPerson.FOV = math.rad(65)
|
||||
FirstPerson.FOCUS_DIST = 24
|
||||
|
||||
-- Pitch limits, in radians below horizontal (positive looks DOWN). The
|
||||
-- world has no ceiling and the sky's bands sit low, so looking far up
|
||||
-- shows the void above the gradient; the up-range is clamped tighter than
|
||||
-- the down-range for that reason, not a technical one.
|
||||
FirstPerson.PITCH_DOWN = math.rad(70)
|
||||
FirstPerson.PITCH_UP = -math.rad(50)
|
||||
FirstPerson.PITCH_DEFAULT = math.rad(10)
|
||||
|
||||
-- how long the dive into (and out of) the head takes, in seconds
|
||||
FirstPerson.BLEND_TIME = 0.45
|
||||
|
||||
-- ------- look input tuning
|
||||
--
|
||||
-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per
|
||||
-- count, the conventional shooter default. STICK rates are radians per
|
||||
-- second at full deflection, with a squared response curve so small
|
||||
-- deflections aim and full ones turn. TOUCH_TURN is what one full screen
|
||||
-- width of drag turns, mobile-shooter convention.
|
||||
FirstPerson.MOUSE_SENS = 0.0032
|
||||
FirstPerson.STICK_YAW = 3.5
|
||||
FirstPerson.STICK_PITCH = 2.4
|
||||
FirstPerson.STICK_DEAD = 0.18
|
||||
FirstPerson.TOUCH_TURN = 2.2 * math.pi
|
||||
FirstPerson.MOVE_DEAD = 0.25
|
||||
|
||||
-- ------- state
|
||||
--
|
||||
-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite
|
||||
-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a
|
||||
-- facing converts to a yaw by table lookup.
|
||||
FirstPerson.yaw = 0
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
FirstPerson.blend = 0
|
||||
|
||||
local wasEngaged = false
|
||||
local stick = { x = 0, y = 0 } -- right stick, latest event values
|
||||
local mouseDX, mouseDY = 0, 0 -- relative counts since last update
|
||||
local lookTouch = nil -- { id, x, y } of the claimed finger
|
||||
local touchMove = nil -- the touch d-pad's analog deflection
|
||||
local captured = false -- mouse relative mode engaged by us
|
||||
|
||||
-- the placed-camera record this module last handed to Voxel3D, so passes
|
||||
-- that key behaviour off "is the first-person rig the one drawing" (the
|
||||
-- billboard yaw, the frame remap) can ask by identity rather than by mode
|
||||
-- -- the battle's own placed camera must never read as first person
|
||||
local rig = nil
|
||||
|
||||
local FACING_ANGLE = {
|
||||
down = 0,
|
||||
right = math.pi / 2,
|
||||
up = math.pi,
|
||||
left = -math.pi / 2,
|
||||
}
|
||||
local FACING_ORDER = { "down", "right", "up", "left" }
|
||||
|
||||
local function wrapPi(a)
|
||||
return (a + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
|
||||
local function ease(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 1ST rung is selected and the 3D pass can carry it.
|
||||
function FirstPerson.engaged()
|
||||
return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
|
||||
end
|
||||
|
||||
-- Whether first person should be READING the player's inputs right now:
|
||||
-- engaged, with the overworld on top of the stack (a menu, a dialog or a
|
||||
-- battle above it owns the buttons, exactly as it does for grid walking).
|
||||
function FirstPerson.driving()
|
||||
if not FirstPerson.engaged() then return false end
|
||||
local ok, top, ow = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return Game.stack and Game.stack:top(), Game.overworld
|
||||
end)
|
||||
return ok and top ~= nil and top == ow
|
||||
end
|
||||
|
||||
-- The eased blend, 0 at the orbit and 1 in the head.
|
||||
function FirstPerson.blendEased()
|
||||
return ease(FirstPerson.blend)
|
||||
end
|
||||
|
||||
-- The blend, but only while the free-roam pass's own rig is the placed
|
||||
-- camera. The battle scene places a camera of its own through the same
|
||||
-- seam, and its cards must keep their stage lean rather than yawing at a
|
||||
-- first-person eye that is not looking at them.
|
||||
function FirstPerson.cardBlend()
|
||||
if not rig or Voxel3D.camera ~= rig then return 0 end
|
||||
return ease(FirstPerson.blend)
|
||||
end
|
||||
|
||||
-- Whether the player's own card should be left out of the camera draw:
|
||||
-- deep enough into the blend that the card would fill the lens from
|
||||
-- inside. The sun pass keeps drawing it either way -- a first-person
|
||||
-- player still throws a shadow on the ground ahead.
|
||||
function FirstPerson.hidePlayer()
|
||||
return FirstPerson.cardBlend() > 0.9
|
||||
end
|
||||
|
||||
-- ------- attitude
|
||||
|
||||
-- Apply a look delta, in radians. Everything that turns the head funnels
|
||||
-- through here, so the clamps live once.
|
||||
function FirstPerson.lookBy(dyaw, dpitch)
|
||||
FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw)
|
||||
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
|
||||
math.min(FirstPerson.PITCH_DOWN,
|
||||
FirstPerson.pitch + dpitch))
|
||||
end
|
||||
|
||||
-- The view direction's flat compass facing, for everything that still
|
||||
-- thinks in the grid's four directions: the cell A interacts with, the
|
||||
-- sprite the sun sees, the direction a blocked slide bonks in.
|
||||
function FirstPerson.compassFacing()
|
||||
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
if math.abs(s) > math.abs(c) then
|
||||
return s > 0 and "right" or "left"
|
||||
end
|
||||
return c > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- The unit look direction, and its flat (ground-plane) part.
|
||||
local function lookDir()
|
||||
local cp = math.cos(FirstPerson.pitch)
|
||||
return math.sin(FirstPerson.yaw) * cp,
|
||||
-math.sin(FirstPerson.pitch),
|
||||
math.cos(FirstPerson.yaw) * cp
|
||||
end
|
||||
|
||||
function FirstPerson.lookFlat()
|
||||
return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
end
|
||||
|
||||
-- ------- billboards seen from inside the world
|
||||
--
|
||||
-- The diorama's cards face south and lean back by the camera's pitch --
|
||||
-- correct for a camera that always stands south. An eye that can stand
|
||||
-- ANYWHERE sees a south-facing card edge-on from the east, so in first
|
||||
-- person every card yaws about its feet to face the eye (cylindrical
|
||||
-- billboarding: upright, never tipping). VoxelScene blends its matrices
|
||||
-- between the two by cardBlend.
|
||||
|
||||
-- The yaw that turns a card's south-facing normal toward the eye.
|
||||
function FirstPerson.cardYaw(wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
if not eye then return 0 end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return 0 end
|
||||
return math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames an entity shows THIS eye: its facing
|
||||
-- rotated into the viewer's own frame, quantised. The flat game's frames
|
||||
-- are "how this pose looks from the south", so the apparent facing is the
|
||||
-- pose rotated by where the viewer actually stands -- walk behind an NPC
|
||||
-- and you see their back, circle to their flank and you see the profile,
|
||||
-- exactly as the four frames Gen 1 drew intend.
|
||||
function FirstPerson.apparentFacing(facing, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
local phi = FACING_ANGLE[facing]
|
||||
if not (eye and phi) then return facing end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return facing end
|
||||
local rel = wrapPi(phi - math.atan2(dx, dz))
|
||||
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
|
||||
return FACING_ORDER[idx + 1]
|
||||
end
|
||||
|
||||
-- ------- the move intent
|
||||
--
|
||||
-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+
|
||||
-- right), mz advances (+ forward). Whichever device is actually deflected
|
||||
-- answers -- the left stick's raw axes first (the engine quantises them to
|
||||
-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger,
|
||||
-- then the held keys. Magnitude caps at 1.
|
||||
function FirstPerson.moveVector()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local input = ok and Game.input or nil
|
||||
|
||||
local ax = input and input.stickAxis or nil
|
||||
if ax then
|
||||
local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y)
|
||||
if mag > FirstPerson.MOVE_DEAD then
|
||||
local t = math.min(1, (mag - FirstPerson.MOVE_DEAD)
|
||||
/ (1 - FirstPerson.MOVE_DEAD))
|
||||
return ax.x / mag * t, -ax.y / mag * t
|
||||
end
|
||||
end
|
||||
|
||||
if touchMove then
|
||||
local mag = math.sqrt(touchMove.x * touchMove.x
|
||||
+ touchMove.y * touchMove.y)
|
||||
if mag > FirstPerson.MOVE_DEAD then
|
||||
local t = math.min(1, mag)
|
||||
return touchMove.x / mag * t, -touchMove.y / mag * t
|
||||
end
|
||||
end
|
||||
|
||||
if input then
|
||||
local mx = (input:isDown("right") and 1 or 0)
|
||||
- (input:isDown("left") and 1 or 0)
|
||||
local mz = (input:isDown("up") and 1 or 0)
|
||||
- (input:isDown("down") and 1 or 0)
|
||||
if mx ~= 0 or mz ~= 0 then
|
||||
local mag = math.sqrt(mx * mx + mz * mz)
|
||||
return mx / mag, mz / mag
|
||||
end
|
||||
end
|
||||
return 0, 0
|
||||
end
|
||||
|
||||
-- Rotate a camera-space move into world space: forward is the flat look
|
||||
-- direction, strafe-right is its right hand. (cross(forward, up) with
|
||||
-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0,
|
||||
-- sin y): face south and your right hand points west.)
|
||||
function FirstPerson.moveWorld(mx, mz)
|
||||
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
return -c * mx + s * mz, s * mx + c * mz
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
|
||||
-- Runs from the pipeline's update hook, every frame whatever the level --
|
||||
-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse
|
||||
-- capture lifecycle, and the frame's stick-rate look.
|
||||
function FirstPerson.update(dt)
|
||||
local engagedNow = FirstPerson.engaged()
|
||||
|
||||
-- entering the rung: the head starts looking the way the sprite faces,
|
||||
-- pitched gently down -- the reading pose of the flat game
|
||||
if engagedNow and not wasEngaged then
|
||||
local ok, facing = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return Game.overworld and Game.overworld.player
|
||||
and Game.overworld.player.facing
|
||||
end)
|
||||
FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
end
|
||||
wasEngaged = engagedNow
|
||||
|
||||
-- the blend, held at flat until there is terrain to dive into -- the
|
||||
-- same wait Voxel.update keeps for the orbit tween, for the same reason
|
||||
local target = engagedNow and 1 or 0
|
||||
if target > FirstPerson.blend and FirstPerson.blend == 0
|
||||
and not Voxel.ready then
|
||||
target = 0
|
||||
end
|
||||
local step = dt / FirstPerson.BLEND_TIME
|
||||
if FirstPerson.blend < target then
|
||||
FirstPerson.blend = math.min(target, FirstPerson.blend + step)
|
||||
elseif FirstPerson.blend > target then
|
||||
FirstPerson.blend = math.max(target, FirstPerson.blend - step)
|
||||
end
|
||||
if FirstPerson.blend <= 0 and rig then
|
||||
-- fully out: let go of the placed camera (unless a battle already
|
||||
-- swapped its own in, which is not ours to clear)
|
||||
if Voxel3D.camera == rig then Voxel3D.camera = nil end
|
||||
rig = nil
|
||||
end
|
||||
|
||||
-- mouse capture follows engagement: captured whenever the rung is on and
|
||||
-- the window has focus, released the moment either ends. Checked against
|
||||
-- the live mode rather than toggled on edges, so a capture lost to the
|
||||
-- OS (alt-tab) re-arms itself on the next focused frame.
|
||||
local wantCapture = engagedNow
|
||||
if wantCapture and love.window and love.window.hasFocus then
|
||||
local okF, focus = pcall(love.window.hasFocus)
|
||||
wantCapture = okF and focus or false
|
||||
end
|
||||
if love.mouse and love.mouse.setRelativeMode then
|
||||
local okM, isRel = pcall(love.mouse.getRelativeMode)
|
||||
if okM and isRel ~= wantCapture then
|
||||
pcall(love.mouse.setRelativeMode, wantCapture)
|
||||
end
|
||||
captured = wantCapture
|
||||
end
|
||||
|
||||
local driving = FirstPerson.driving()
|
||||
|
||||
-- The mouse's counts, accumulated by the wrapped handler since the last
|
||||
-- tick; dropped unread while something else owns the screen.
|
||||
--
|
||||
-- The yaw sign is NEGATED, here and in every look input below: yaw grows
|
||||
-- south -> east -> north (the world runs +X east, +Z south, and the
|
||||
-- direction is (sin yaw, cos yaw)), which seen from behind the eye is a
|
||||
-- LEFT turn -- so "move the mouse right, look right" means subtracting.
|
||||
local dx, dy = mouseDX, mouseDY
|
||||
mouseDX, mouseDY = 0, 0
|
||||
if driving and (dx ~= 0 or dy ~= 0) then
|
||||
FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS,
|
||||
dy * FirstPerson.MOUSE_SENS)
|
||||
end
|
||||
|
||||
-- the right stick is a rate: radians per second, squared response so
|
||||
-- the first half of the throw aims and the rest turns
|
||||
if driving then
|
||||
local rx, ry = stick.x, stick.y
|
||||
local function curve(v)
|
||||
local a = math.abs(v)
|
||||
if a < FirstPerson.STICK_DEAD then return 0 end
|
||||
a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD)
|
||||
return (v < 0 and -1 or 1) * a * a
|
||||
end
|
||||
local cy, cp = curve(rx), curve(ry)
|
||||
if cy ~= 0 or cp ~= 0 then
|
||||
-- negated yaw for the same reason as the mouse above
|
||||
FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt,
|
||||
cp * FirstPerson.STICK_PITCH * dt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the rig itself
|
||||
|
||||
-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same
|
||||
-- arithmetic Voxel3D.viewProjection runs, restated here because the blend
|
||||
-- needs both ends as DATA. Kept textually tiny so the two cannot drift:
|
||||
-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular
|
||||
-- in the YZ plane.
|
||||
local function orbitRig(cx, cy, vh)
|
||||
local a = Voxel.angle
|
||||
local dist = Voxel.FOCAL * vh
|
||||
return { cx, dist * math.cos(a), cy + dist * math.sin(a) },
|
||||
{ cx, 0, cy },
|
||||
2 * math.atan(1 / (2 * Voxel.FOCAL)),
|
||||
{ 0, math.sin(a), -math.cos(a) }
|
||||
end
|
||||
|
||||
local lastEye = nil -- frozen head pose for player-less frames
|
||||
|
||||
-- Build this frame's placed camera and hand it to Voxel3D, plus the scene
|
||||
-- centre the curve and the depth reference should use. `me` is the
|
||||
-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and
|
||||
-- (cx, cy) the orbit's own view centre.
|
||||
--
|
||||
-- Returns nil with the blend fully out, which is the caller's signal to
|
||||
-- leave the orbit in charge.
|
||||
function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
local b = FirstPerson.blend
|
||||
if b <= 0 then
|
||||
if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end
|
||||
rig = nil
|
||||
return nil
|
||||
end
|
||||
local e = ease(b)
|
||||
|
||||
local head
|
||||
if me then
|
||||
head = { me.px + 8,
|
||||
(me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT,
|
||||
me.py + 8 }
|
||||
lastEye = head
|
||||
else
|
||||
head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy }
|
||||
end
|
||||
local lx, ly, lz = lookDir()
|
||||
local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST,
|
||||
head[2] + ly * FirstPerson.FOCUS_DIST,
|
||||
head[3] + lz * FirstPerson.FOCUS_DIST }
|
||||
|
||||
local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
|
||||
local function mix(p, q)
|
||||
return { p[1] + (q[1] - p[1]) * e,
|
||||
p[2] + (q[2] - p[2]) * e,
|
||||
p[3] + (q[3] - p[3]) * e }
|
||||
end
|
||||
local up = mix(oUp, { 0, 1, 0 })
|
||||
local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3])
|
||||
if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul
|
||||
else up = { 0, 1, 0 } end
|
||||
|
||||
-- the world curve eases out with the blend: standing inside the world,
|
||||
-- the bend that sells the diorama reads as the ground falling away. A
|
||||
-- true zero (curve declined) needs the field present -- nil would let
|
||||
-- Voxel3D fall back to the setting
|
||||
local k = WorldCurve.k(vh) * (1 - e)
|
||||
|
||||
rig = {
|
||||
eye = mix(oEye, head),
|
||||
focus = mix(oFocus, fpFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
}
|
||||
Voxel3D.camera = rig
|
||||
|
||||
-- the scene centre walks from the orbit's view centre to the head, so
|
||||
-- the curve's focus, the depth reference and the glint's travel follow
|
||||
-- the camera that is actually in charge
|
||||
local sx = cx + (head[1] - cx) * e
|
||||
local sy = cy + (head[3] - cy) * e
|
||||
return rig, sx, sy
|
||||
end
|
||||
|
||||
-- Where the shadow pass should centre its box: pushed along the flat look
|
||||
-- so the fitted frustum -- built for an orbit that always looks north --
|
||||
-- covers the ground THIS camera sees. The push is strongest looking
|
||||
-- south (the direction the orbit's box barely reaches) and scales with
|
||||
-- the blend.
|
||||
function FirstPerson.shadowCenter(sx, sy, vh)
|
||||
local e = FirstPerson.cardBlend()
|
||||
if e <= 0 then return sx, sy end
|
||||
local fx, fz = FirstPerson.lookFlat()
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local cap = (ShadowMap.FAR_CAP or 2.5) * vh
|
||||
return sx + fx * 0.6 * vh * e,
|
||||
sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e
|
||||
end
|
||||
|
||||
-- The first-person facts a shadow signature has to include: the sun's
|
||||
-- box is fitted around this camera, so turning the head or walking the
|
||||
-- blend has to re-fit it even standing still.
|
||||
function FirstPerson.signature()
|
||||
local b = FirstPerson.blend
|
||||
if b <= 0 then return "" end
|
||||
return table.concat({
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
}, ",")
|
||||
end
|
||||
|
||||
-- ------- input capture
|
||||
--
|
||||
-- The seams: relative mouse motion has no Game handler at all (the
|
||||
-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the
|
||||
-- right stick's axes are explicitly ignored by Input, and a touch
|
||||
-- anywhere off the overlay's controls dies in TouchControls. Each wrap
|
||||
-- forwards everything it does not claim, and claims only while first
|
||||
-- person is actually driving -- so with the rung off, every byte flows
|
||||
-- exactly where it always did.
|
||||
|
||||
local installed = false
|
||||
|
||||
function FirstPerson.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- ------- right stick
|
||||
do
|
||||
local inner = Game.gamepadaxis
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
if axis == "rightx" then stick.x = value
|
||||
elseif axis == "righty" then stick.y = value end
|
||||
return inner(self, joystick, axis, value)
|
||||
end
|
||||
end
|
||||
-- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL
|
||||
-- convention and Input already claims them; 3/4 are the usual right
|
||||
-- pair on the same class of device. Real gamepads are excluded -- they
|
||||
-- already spoke through the mapped rightx/righty above, and their RAW
|
||||
-- axis 3 is as likely a trigger as a stick.
|
||||
--
|
||||
-- Two more exclusions, both learned the hard way on Android, where this
|
||||
-- wrap runs BEFORE the engine's own generic-joystick guards:
|
||||
--
|
||||
-- the accelerometer arrives as a joystick named for what it is, with
|
||||
-- gravity pinning an axis well past any deadzone -- the same device
|
||||
-- Game:joystickaxis refuses for movement (#459), refused here by the
|
||||
-- same name test, or the view spins on its own the moment 1ST opens.
|
||||
--
|
||||
-- and a raw axis is only BELIEVED after it has been seen near centre
|
||||
-- once. A stick at rest sits at zero, so a real one earns trust with
|
||||
-- its first touch; a gravity-pinned sensor axis or a trigger resting
|
||||
-- at an extreme never centres and so never steers the look.
|
||||
local function isAccelerometer(joystick)
|
||||
local ok, name = pcall(function() return joystick:getName() end)
|
||||
return ok and type(name) == "string"
|
||||
and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
local rawCentred = {}
|
||||
do
|
||||
local inner = Game.joystickaxis
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
local mapped = joystick and joystick.isGamepad and joystick:isGamepad()
|
||||
if not mapped and (axis == 3 or axis == 4)
|
||||
and not isAccelerometer(joystick) then
|
||||
if math.abs(value) < 0.3 then rawCentred[axis] = true end
|
||||
if rawCentred[axis] then
|
||||
if axis == 3 then stick.x = value else stick.y = value end
|
||||
end
|
||||
end
|
||||
return inner(self, joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- mouse
|
||||
--
|
||||
-- love.mousemoved rather than a Game method, because the engine has no
|
||||
-- Game:mousemoved to wrap -- the callback in the project's main.lua is
|
||||
-- the one place relative counts arrive. Claimed only while captured;
|
||||
-- pass-through otherwise, including the mouse-as-touch path.
|
||||
do
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(x, y, dx, dy, istouch)
|
||||
if captured and not istouch then
|
||||
mouseDX = mouseDX + (dx or 0)
|
||||
mouseDY = mouseDY + (dy or 0)
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, dx, dy, istouch) end
|
||||
end
|
||||
end
|
||||
-- While the mouse is captured there is no cursor to click UI with, so
|
||||
-- the buttons become GB buttons: left is A, right is B -- through the
|
||||
-- overlay's own press path, which a rebind can never detach. What WE
|
||||
-- pressed is remembered per button, so the release always reaches the
|
||||
-- overlay even if the capture ended while the button was down --
|
||||
-- otherwise a click that outlives the rung strands A held forever.
|
||||
local mouseHeld = {}
|
||||
local MOUSE_BTN = { [1] = "a", [2] = "b" }
|
||||
do
|
||||
local inner = love.mousepressed
|
||||
love.mousepressed = function(x, y, button, istouch, presses)
|
||||
if captured and not istouch and MOUSE_BTN[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = true
|
||||
Input:overlayPressed(MOUSE_BTN[button])
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, button, istouch, presses) end
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = love.mousereleased
|
||||
love.mousereleased = function(x, y, button, istouch, presses)
|
||||
if mouseHeld[button] then
|
||||
local Input = require("src.core.Input")
|
||||
mouseHeld[button] = nil
|
||||
Input:overlayReleased(MOUSE_BTN[button])
|
||||
return
|
||||
end
|
||||
if inner then return inner(x, y, button, istouch, presses) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- touch
|
||||
--
|
||||
-- A finger on open screen -- not on the overlay's d-pad or buttons --
|
||||
-- becomes the look drag. One finger owns the look at a time; every
|
||||
-- other touch flows to TouchControls untouched, so a thumb can drag the
|
||||
-- view while the other walks the d-pad. That d-pad finger is also read
|
||||
-- back ANALOG here: TouchControls quantises it to four directions for
|
||||
-- the grid game, but the deflection it quantised is exactly the move
|
||||
-- vector a free walk wants.
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local function dpadVector(x, y)
|
||||
local ok, v = pcall(function()
|
||||
local L = TouchControls:layout()
|
||||
local dz = L.dpad
|
||||
local half = dz.w * 0.65
|
||||
return { x = math.max(-1, math.min(1, (x - dz.cx) / half)),
|
||||
y = math.max(-1, math.min(1, (y - dz.cy) / half)) }
|
||||
end)
|
||||
return ok and v or nil
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchpressed
|
||||
function Game:touchpressed(id, x, y)
|
||||
if FirstPerson.driving() then
|
||||
local onControl = nil
|
||||
pcall(function() onControl = TouchControls:hitTest(x, y) end)
|
||||
if not onControl and not lookTouch then
|
||||
lookTouch = { id = id, x = x, y = y }
|
||||
return
|
||||
end
|
||||
inner(self, id, x, y)
|
||||
if onControl == "dpad" and TouchControls.dpadTouch == id then
|
||||
touchMove = dpadVector(x, y)
|
||||
end
|
||||
return
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = Game.touchmoved
|
||||
function Game:touchmoved(id, x, y)
|
||||
if lookTouch and lookTouch.id == id then
|
||||
local w = 1280
|
||||
pcall(function() w = love.graphics.getWidth() end)
|
||||
local per = FirstPerson.TOUCH_TURN / math.max(320, w)
|
||||
if FirstPerson.driving() then
|
||||
-- negated yaw for the same reason as the mouse (see update):
|
||||
-- drag right, look right, the mobile-shooter convention
|
||||
FirstPerson.lookBy(-(x - lookTouch.x) * per,
|
||||
(y - lookTouch.y) * per)
|
||||
end
|
||||
lookTouch.x, lookTouch.y = x, y
|
||||
return
|
||||
end
|
||||
if touchMove and TouchControls.dpadTouch == id then
|
||||
touchMove = dpadVector(x, y) or touchMove
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
do
|
||||
local inner = Game.touchreleased
|
||||
function Game:touchreleased(id, x, y)
|
||||
if lookTouch and lookTouch.id == id then
|
||||
lookTouch = nil
|
||||
return
|
||||
end
|
||||
if TouchControls.dpadTouch == id then touchMove = nil end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- a reset that drops held input state drops ours with it
|
||||
do
|
||||
local inner = Game.focus
|
||||
function Game:focus(f)
|
||||
lookTouch, touchMove = nil, nil
|
||||
stick.x, stick.y = 0, 0
|
||||
mouseDX, mouseDY = 0, 0
|
||||
return inner(self, f)
|
||||
end
|
||||
end
|
||||
-- a disconnected controller cannot send the centering event for whatever
|
||||
-- its stick last held -- the engine drops all input state here, and the
|
||||
-- look rate (plus the raw axes' earned trust) goes with it
|
||||
do
|
||||
local inner = Game.joystickremoved
|
||||
function Game:joystickremoved(joystick)
|
||||
stick.x, stick.y = 0, 0
|
||||
rawCentred[3], rawCentred[4] = nil, nil
|
||||
return inner(self, joystick)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return FirstPerson
|
||||
@@ -0,0 +1,345 @@
|
||||
-- Voxel world mode: free movement for the first-person rung.
|
||||
--
|
||||
-- The engine walks a grid: sixteen frames per cell, four directions,
|
||||
-- input locked mid-step. Inside a first-person camera that gait reads as
|
||||
-- riding a rail, so while 1ST drives, this module replaces the WALK and
|
||||
-- nothing else: the player's position becomes continuous, steered by the
|
||||
-- camera's own yaw -- push forward and you go where you look, at any
|
||||
-- angle, sliding along whatever you graze.
|
||||
--
|
||||
-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
|
||||
-- about cells -- what blocks, what warps, what rustles, what bites -- and
|
||||
-- this module keeps the player's logical cell synced to wherever the free
|
||||
-- walk stands, then reuses the engine's own machinery for every one of
|
||||
-- those questions:
|
||||
--
|
||||
-- passability the same isWalkableCell / water-while-surfing /
|
||||
-- tile-pair / entity-occupancy verdicts Collision
|
||||
-- hands the grid walker, asked per cell the player's
|
||||
-- body overlaps.
|
||||
--
|
||||
-- cell arrival OverworldState:onStepComplete, the same landing
|
||||
-- pipeline a grid step runs -- warps, spinners, gates,
|
||||
-- forced currents, poison, repel, encounters, the
|
||||
-- step counters -- fired once per cell crossed, which
|
||||
-- is exactly the rate a grid walk fires it.
|
||||
--
|
||||
-- the special pushes walking off the map edge, into a ledge, or into
|
||||
-- a boulder hands the quantised direction straight to
|
||||
-- checkEdgeExit / checkLedgeHop / checkBoulderPush,
|
||||
-- the engine's own handlers, which validate and stage
|
||||
-- everything themselves (connections, the hop arc,
|
||||
-- the two-push arm). While any of those animates a
|
||||
-- scripted grid move, this module stands aside and
|
||||
-- adopts the result.
|
||||
--
|
||||
-- Nothing here writes save state, rolls encounters, or decides what a
|
||||
-- warp does -- it moves a point, keeps the cell honest, and lets the
|
||||
-- engine be the engine. Stepping off the rung snaps the point to its
|
||||
-- cell and hands the walk back to the grid, and with the rung off this
|
||||
-- module costs one gate check per frame.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
|
||||
local FreeMove = {}
|
||||
|
||||
-- The body: a circle in the ground plane. Small enough to walk every
|
||||
-- one-cell corridor the grid game has (half a cell is 8), big enough to
|
||||
-- keep the eye's near plane out of wall faces when sliding along them.
|
||||
FreeMove.RADIUS = 5.5
|
||||
|
||||
-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16
|
||||
-- frames per 16px cell on foot, 8 on the bike), so distance covered per
|
||||
-- second is unchanged and the encounter rate per tile crossed stays the
|
||||
-- game's own.
|
||||
FreeMove.WALK = 1.0
|
||||
FreeMove.BIKE = 2.0
|
||||
|
||||
local EPS = 0.01
|
||||
|
||||
-- the free position (player centre, world px) and the px/py we last wrote
|
||||
-- -- if they differ from the player's, something else (a warp, a script)
|
||||
-- moved them, and the free walk adopts rather than fights
|
||||
local pos = nil
|
||||
local lastPx, lastPy = nil, nil
|
||||
|
||||
local function adopt(p)
|
||||
pos = { x = p.px + 8, z = p.py + 8 }
|
||||
lastPx, lastPy = p.px, p.py
|
||||
end
|
||||
|
||||
function FreeMove.drop()
|
||||
pos = nil
|
||||
end
|
||||
|
||||
-- named for the suite: the module's live position, nil while dropped
|
||||
function FreeMove._pos()
|
||||
return pos
|
||||
end
|
||||
|
||||
-- ------- the per-cell verdict
|
||||
--
|
||||
-- The same questions Collision.canMove asks for a grid step, asked of one
|
||||
-- cell from the player's current standing. The player's OWN cell never
|
||||
-- blocks -- the body must always be free to leave wherever it stands
|
||||
-- (a warp mat, the water it is surfing, a cell an NPC just stepped
|
||||
-- against).
|
||||
|
||||
local function pairBlocked(map, surfing, sx, sy, tx, ty)
|
||||
local Game = require("src.core.Game")
|
||||
local tp = Game.data and Game.data.field and Game.data.field.tilePairs
|
||||
if not tp then return false end
|
||||
local list = surfing and tp.water or tp.land
|
||||
if not list or #list == 0 then return false end
|
||||
local tileset = map.def.tileset
|
||||
local a = map:cellTile(sx, sy)
|
||||
local b = map:cellTile(tx, ty)
|
||||
for _, p in ipairs(list) do
|
||||
if p.tileset == tileset
|
||||
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Why (cx, cy) refuses the player's body, or nil when it may enter:
|
||||
-- "bounds" | "tile" | "entity", the grid verdict's own names.
|
||||
local function blockedCell(state, p, cx, cy)
|
||||
if cx == p.cellX and cy == p.cellY then return nil end
|
||||
local map = state.map
|
||||
if not map:inBounds(cx, cy) then return "bounds" end
|
||||
if not map:isWalkableCell(cx, cy) then
|
||||
if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end
|
||||
end
|
||||
if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then
|
||||
return "tile"
|
||||
end
|
||||
local Collision = require("src.world.Collision")
|
||||
if Collision.occupied(state.entities, cx, cy, p) then return "entity" end
|
||||
return nil
|
||||
end
|
||||
|
||||
FreeMove._blockedCell = blockedCell -- named for the suite
|
||||
|
||||
-- ------- the slide
|
||||
--
|
||||
-- One axis at a time, clamped at the first refusing cell's face: the
|
||||
-- classic axis-separated walk, which is where wall-sliding comes from --
|
||||
-- the blocked axis stops and the free one keeps going. Returns the
|
||||
-- refusal ("bounds"/"tile"/"entity") when this axis was clamped.
|
||||
|
||||
local function slideX(state, p, dx)
|
||||
if dx == 0 then return nil end
|
||||
local r = FreeMove.RADIUS
|
||||
local nx = pos.x + dx
|
||||
local z0 = math.floor((pos.z - r + EPS) / 16)
|
||||
local z1 = math.floor((pos.z + r - EPS) / 16)
|
||||
local hit = nil
|
||||
local edge = dx > 0 and math.floor((nx + r) / 16)
|
||||
or math.floor((nx - r) / 16)
|
||||
for zc = z0, z1 do
|
||||
hit = blockedCell(state, p, edge, zc)
|
||||
if hit then break end
|
||||
end
|
||||
if hit then
|
||||
if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS)
|
||||
else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end
|
||||
end
|
||||
pos.x = nx
|
||||
return hit
|
||||
end
|
||||
|
||||
local function slideZ(state, p, dz)
|
||||
if dz == 0 then return nil end
|
||||
local r = FreeMove.RADIUS
|
||||
local nz = pos.z + dz
|
||||
local x0 = math.floor((pos.x - r + EPS) / 16)
|
||||
local x1 = math.floor((pos.x + r - EPS) / 16)
|
||||
local hit = nil
|
||||
local edge = dz > 0 and math.floor((nz + r) / 16)
|
||||
or math.floor((nz - r) / 16)
|
||||
for xc = x0, x1 do
|
||||
hit = blockedCell(state, p, xc, edge)
|
||||
if hit then break end
|
||||
end
|
||||
if hit then
|
||||
if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS)
|
||||
else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end
|
||||
end
|
||||
pos.z = nz
|
||||
return hit
|
||||
end
|
||||
|
||||
-- ------- the blocked push
|
||||
--
|
||||
-- The grid game's blocked step is where half its verbs live: the map-edge
|
||||
-- crossing, the ledge hop, the boulder shove, the route-gate warp fired
|
||||
-- by collision, and the honest bonk. Hand the engine the quantised
|
||||
-- direction and let its own handlers decide -- each one validates itself
|
||||
-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds),
|
||||
-- so calling them on every firm push is safe. Returns true when one of
|
||||
-- them took the frame over.
|
||||
local function pushSpecials(state, dir, why)
|
||||
local p = state.player
|
||||
p.facing = dir -- the handlers read the push off the facing
|
||||
if why == "bounds" and state:checkEdgeExit(dir) then return true end
|
||||
if state:checkLedgeHop(dir) then return true end
|
||||
if state:checkBoulderPush(dir) then return true end
|
||||
if why ~= "entity" and state:canCollisionWarp() then
|
||||
local Game = require("src.core.Game")
|
||||
local Warp = require("src.world.Warp")
|
||||
local w = Warp.onCollision(state.map, Game.data.field.warpCarpets,
|
||||
p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
state:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if why ~= "entity" then
|
||||
if (state.bumpCooldown or 0) <= 0 then
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
state.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Runs in place of OverworldState:handleInput while first person drives
|
||||
-- (see install below), which means it inherits every gate the grid walk
|
||||
-- has: never during scripted moves, transitions, or with anything above
|
||||
-- the overworld on the stack.
|
||||
|
||||
function FreeMove.tick(state)
|
||||
local p = state.player
|
||||
|
||||
-- a grid move is animating -- a ledge hop, a spinner slide, a scripted
|
||||
-- walk -- or a cutscene owns the player: stand aside, adopt the result
|
||||
if p.moving or p.inputLocked then
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local input = Game.input
|
||||
|
||||
-- the head is the facing: what A talks to, what the sun's card shows,
|
||||
-- which way a bonk points
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
|
||||
if input:wasPressed("a") then
|
||||
state:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
require("src.ui.Screens").push(Game, "StartMenu")
|
||||
return
|
||||
end
|
||||
|
||||
local mx, mz = FirstPerson.moveVector()
|
||||
local wx, wz = FirstPerson.moveWorld(mx, mz)
|
||||
|
||||
-- Cycling Road's downhill pull, the free-walk restatement of the grid
|
||||
-- path's simulated PAD_DOWN: south drift with nothing held, braked by
|
||||
-- holding A or B exactly as the Route 17 sign promises
|
||||
local moving = (mx ~= 0 or mz ~= 0)
|
||||
if not moving and Game.save and Game.save.onBike then
|
||||
local fm = Game.data.field.forcedMovement
|
||||
local braking = input:isDown("a") or input:isDown("b")
|
||||
if fm and not braking then
|
||||
for _, m in ipairs(fm.slopeMaps or {}) do
|
||||
if m == state.map.id then
|
||||
wx, wz, moving = 0, 1, true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not moving then return end
|
||||
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
or FreeMove.WALK
|
||||
local dx, dz = wx * speed, wz * speed
|
||||
|
||||
local hitX = slideX(state, p, dx)
|
||||
local hitZ = slideZ(state, p, dz)
|
||||
|
||||
-- the walk cycle: the wall-bonk clock animates the legs of a player the
|
||||
-- grid thinks is standing still, refreshed while the free walk covers
|
||||
-- ground (Player:update ticks animClock off it; walkPhase reads it)
|
||||
p.bumpFrames = 2
|
||||
|
||||
p.px, p.py = pos.x - 8, pos.z - 8
|
||||
lastPx, lastPy = p.px, p.py
|
||||
|
||||
-- the cell the body stands in; crossing into a new one IS a step
|
||||
local ncx = math.floor(pos.x / 16)
|
||||
local ncy = math.floor(pos.z / 16)
|
||||
if ncx ~= p.cellX or ncy ~= p.cellY then
|
||||
p.cellX, p.cellY = ncx, ncy
|
||||
state:onStepComplete()
|
||||
-- a warp or a battle may have moved the world out from under the
|
||||
-- walk; the adopt check on the next tick picks the pieces up
|
||||
return
|
||||
end
|
||||
|
||||
-- a firm push into something that refused: the engine's own blocked-step
|
||||
-- verbs, aimed the way the push leans
|
||||
local hit, dir
|
||||
if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then
|
||||
hit, dir = hitX, (dx > 0 and "right" or "left")
|
||||
elseif hitZ then
|
||||
hit, dir = hitZ, (dz > 0 and "down" or "up")
|
||||
end
|
||||
if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then
|
||||
if pushSpecials(state, dir, hit) then
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
-- the push handlers may have turned the facing; the head still rules
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the seam
|
||||
--
|
||||
-- OverworldState:handleInput is the one choke point where the grid walk
|
||||
-- reads the pad -- the same seam the engine's own Cycling Road pull and
|
||||
-- collision warps live behind -- so replacing the walk means wrapping it
|
||||
-- and nothing else. Every gate ABOVE the call (scripted moves, trainer
|
||||
-- engagement, transitions, anything on the stack) still applies to the
|
||||
-- free walk, because the wrap sits below them all.
|
||||
function FreeMove.install()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if OverworldState.dramaticShapeFreeMoveHook then return end
|
||||
local inner = OverworldState.handleInput
|
||||
|
||||
function OverworldState:handleInput()
|
||||
if not FirstPerson.driving() then
|
||||
if pos then
|
||||
-- stepping off the rung: back onto the grid, on the cell the
|
||||
-- free walk stood in
|
||||
local p = self.player
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
FreeMove.drop()
|
||||
end
|
||||
return inner(self)
|
||||
end
|
||||
return FreeMove.tick(self)
|
||||
end
|
||||
|
||||
OverworldState.dramaticShapeFreeMoveHook = true
|
||||
end
|
||||
|
||||
return FreeMove
|
||||
@@ -0,0 +1,174 @@
|
||||
-- Voxel world mode: the glass in the windows, found rather than listed.
|
||||
--
|
||||
-- Buildings and doors in the overworld art carry small panes -- six texels
|
||||
-- wide, framed in black, with a diagonal shine drawn in. This module finds
|
||||
-- them by SHAPE in the tileset image itself: a border row of six black
|
||||
-- texels, four or five rows of black-flanked non-black glass under it, and
|
||||
-- a closing border row. No tile ids are hardcoded, so a total conversion
|
||||
-- that draws its own windows in the same idiom gets glass for free, and art
|
||||
-- with no windows gets an empty mask and costs nothing.
|
||||
--
|
||||
-- The scan slides at PIXEL granularity because the art does: the building
|
||||
-- window sits a row down inside its tile, and the door's pane straddles a
|
||||
-- 2x2 tile block entirely -- a per-tile matcher finds neither.
|
||||
--
|
||||
-- What the scan yields is a MASK TEXTURE the same size as the tileset
|
||||
-- atlas: opaque white on glass texels, transparent everywhere else. Terrain
|
||||
-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect),
|
||||
-- so the scene shader can sample this mask with the SAME coordinates and
|
||||
-- know, per fragment, whether it is drawing glass -- on any wall, at any
|
||||
-- angle, in free-roam or a staged battle, with no geometry work anywhere.
|
||||
-- The recoloured atlases (display modes, RED++) keep the tileset's layout,
|
||||
-- so the alignment holds under every palette.
|
||||
--
|
||||
-- What the shader does with the answer (Voxel3D): by day a thin glint
|
||||
-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving
|
||||
-- the art under it -- and after dark the panes are LIT: the texel's own
|
||||
-- shine pattern, warmed and brightened, exempt from the sun, the shadow
|
||||
-- map and the hour's tint, as a window with a lamp behind it is.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local GlassMask = {}
|
||||
|
||||
-- pane geometry the scan accepts: six glass texels across, and this many
|
||||
-- rows of them between the two border rows
|
||||
GlassMask.GLASS_W = 6
|
||||
GlassMask.ROWS = { 4, 5 } -- door pane, building pane
|
||||
|
||||
-- Whether a channel triple is the border black. The raw tileset art is the
|
||||
-- four DMG greys, so black is genuinely zero; the threshold forgives a
|
||||
-- rescaled asset without accepting the dark grey rung (85/255 = 0.33).
|
||||
local function isBlack(r, g, b)
|
||||
return r < 0.12 and g < 0.12 and b < 0.12
|
||||
end
|
||||
|
||||
GlassMask._isBlack = isBlack -- named for the suite
|
||||
|
||||
-- Find every pane in an image, through a pure reader so the geometry is
|
||||
-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based
|
||||
-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels
|
||||
-- (the border is the detector's evidence, not part of the answer).
|
||||
function GlassMask.scan(getPixel, w, h)
|
||||
local function black(x, y)
|
||||
return isBlack(getPixel(x, y))
|
||||
end
|
||||
local function borderRow(x, y)
|
||||
for c = 1, 6 do
|
||||
if not black(x + c, y) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local function glassRow(x, y)
|
||||
if not (black(x, y) and black(x + 7, y)) then return false end
|
||||
for c = 1, 6 do
|
||||
if black(x + c, y) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local want = {}
|
||||
for _, n in ipairs(GlassMask.ROWS) do want[n] = true end
|
||||
|
||||
local rects = {}
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 8 do
|
||||
if borderRow(x, y) then
|
||||
local n = 0
|
||||
while y + 1 + n < h and glassRow(x, y + 1 + n) do
|
||||
n = n + 1
|
||||
end
|
||||
if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then
|
||||
rects[#rects + 1] = { x = x + 1, y = y + 1,
|
||||
w = GlassMask.GLASS_W, h = n }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return rects
|
||||
end
|
||||
|
||||
-- ------- the runtime cache, one entry per tileset image
|
||||
|
||||
local cache = {} -- image path -> { rects, texture (or false) }
|
||||
|
||||
local function entry(tileset)
|
||||
local path = tileset and tileset.image
|
||||
if not path then return nil end
|
||||
local hit = cache[path]
|
||||
if hit then return hit end
|
||||
local ok, data = pcall(Assets.imageData, path)
|
||||
if not (ok and data) then
|
||||
-- unreadable art is a verdict for the session, not a retry loop
|
||||
cache[path] = { rects = {}, texture = false }
|
||||
return cache[path]
|
||||
end
|
||||
local w, h = data:getDimensions()
|
||||
local rects = GlassMask.scan(function(x, y)
|
||||
return data:getPixel(x, y)
|
||||
end, w, h)
|
||||
local texture = false
|
||||
if #rects > 0 and love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage then
|
||||
local built = pcall(function()
|
||||
local mask = love.image.newImageData(w, h)
|
||||
for _, r in ipairs(rects) do
|
||||
for yy = r.y, r.y + r.h - 1 do
|
||||
for xx = r.x, r.x + r.w - 1 do
|
||||
mask:setPixel(xx, yy, 1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
texture = love.graphics.newImage(mask)
|
||||
texture:setFilter("nearest", "nearest")
|
||||
end)
|
||||
if not built then texture = false end
|
||||
end
|
||||
cache[path] = { rects = rects, texture = texture }
|
||||
return cache[path]
|
||||
end
|
||||
|
||||
-- The panes found in a tileset's art, as glass rects in atlas pixels.
|
||||
function GlassMask.rects(tileset)
|
||||
local e = entry(tileset)
|
||||
return e and e.rects or {}
|
||||
end
|
||||
|
||||
-- The mask texture for a tileset, or nil when it has no panes (or the art
|
||||
-- is unreadable, or there is no GPU) -- callers bind the blank instead.
|
||||
function GlassMask.texture(tileset)
|
||||
local e = entry(tileset)
|
||||
return (e and e.texture) or nil
|
||||
end
|
||||
|
||||
-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask:
|
||||
-- the scene shader always declares the sampler, and an unbound sampler is
|
||||
-- a driver-dependent crash rather than a fallback.
|
||||
local blank = nil
|
||||
|
||||
function GlassMask.blank()
|
||||
if blank == nil then
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(1, 1)
|
||||
data:setPixel(0, 0, 0, 0, 0, 0)
|
||||
return love.graphics.newImage(data)
|
||||
end)
|
||||
blank = (ok and img) or false
|
||||
end
|
||||
return blank or nil
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload). The rects survive --
|
||||
-- they are a fact about the art -- but textures are rebuilt on demand.
|
||||
function GlassMask.invalidate()
|
||||
for _, e in pairs(cache) do
|
||||
if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end
|
||||
e.texture = false
|
||||
end
|
||||
cache = {}
|
||||
blank = nil
|
||||
end
|
||||
|
||||
return GlassMask
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Voxel world mode: decoded pixels, kept.
|
||||
--
|
||||
-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads
|
||||
-- resolve the same way but stay uncached: the caller keeps the derived
|
||||
-- product" (src/render/Assets.lua) -- which is the right contract for the
|
||||
-- flat renderer, whose one caller decodes a strip once and keeps the strip.
|
||||
--
|
||||
-- This mod is not that caller. It reads the same handful of images over and
|
||||
-- over, from several places that do not know about each other:
|
||||
--
|
||||
-- * the tileset atlas, decoded by Structures (its own cache), by
|
||||
-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by
|
||||
-- TerrainAtlas again to learn a tile's shades, and by GlassMask;
|
||||
-- * the FLOWER FRAME files, decoded inside patch() -- which runs every
|
||||
-- time the animation step turns over, about three times a second, for
|
||||
-- as long as the map is on screen. That one is not a load cost at all,
|
||||
-- it is a recurring per-second cost on the render thread, and it was
|
||||
-- the single clearest waste the first profile turned up.
|
||||
--
|
||||
-- So: one table, keyed by the path as the CALLER gave it, holding the
|
||||
-- decoded ImageData. Registered with Assets.invalidate so a hot reload
|
||||
-- drops it alongside every other downstream cache.
|
||||
--
|
||||
-- The entries are never evicted by size. That is deliberate and bounded:
|
||||
-- what lands here is tileset art and animation frames -- a few dozen small
|
||||
-- images for a whole session, tens of kilobytes each -- not per-map bakes,
|
||||
-- which have their own eviction in TerrainAtlas.setLive.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Perf = V.require("Perf")
|
||||
|
||||
local ImageCache = {}
|
||||
|
||||
local cache = {}
|
||||
|
||||
-- The decoded pixels for `path`, or nil when it cannot be read.
|
||||
--
|
||||
-- `false` is cached for an unreadable path, so a missing or corrupt asset
|
||||
-- costs one failed decode for the session rather than one per frame -- the
|
||||
-- same sticky-failure shape the rest of this mod uses for GPU objects.
|
||||
function ImageCache.get(path)
|
||||
if not path then return nil end
|
||||
local hit = cache[path]
|
||||
if hit ~= nil then
|
||||
Perf.count("imageCache.hit")
|
||||
return hit or nil
|
||||
end
|
||||
local t0 = Perf.now()
|
||||
local ok, data = pcall(Assets.imageData, path)
|
||||
Perf.add("ImageCache.decode", t0)
|
||||
Perf.count("imageCache.miss")
|
||||
cache[path] = (ok and data) or false
|
||||
return cache[path] or nil
|
||||
end
|
||||
|
||||
function ImageCache.invalidate()
|
||||
cache = {}
|
||||
end
|
||||
|
||||
Assets.register(ImageCache.invalidate)
|
||||
|
||||
return ImageCache
|
||||
+463
-13
@@ -72,6 +72,63 @@ function OverworldBattle.enabled()
|
||||
return OverworldBattle.setting:get() and true or false
|
||||
end
|
||||
|
||||
-- ------- BACK SPRITES: the player's own mon stays on the menu
|
||||
--
|
||||
-- The staged shot stands BOTH mons on the map, which is the mode's whole
|
||||
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
|
||||
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
|
||||
-- with its feet on the box. That silhouette is the series' shot.
|
||||
--
|
||||
-- So BACK SPRITES is offered as a middle setting rather than a compromise
|
||||
-- imposed on everyone. With it on the foe is still geometry standing on its
|
||||
-- tile at the far end of the arena, and the player's side goes back to being
|
||||
-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
|
||||
-- feet on row 96.
|
||||
-- Nothing else about the shot moves -- the arena, the camera and the drift are
|
||||
-- solved exactly as they were, so the foe stands where it always stood and the
|
||||
-- player's cell is simply empty ground in the foreground.
|
||||
--
|
||||
-- OFF by default: what the mode advertises is the pair of them out there.
|
||||
OverworldBattle.BACK_KEY = "battleBack"
|
||||
OverworldBattle.BACK_LABEL = "BACK SPRITES"
|
||||
|
||||
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
||||
OverworldBattle.BACK_LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
|
||||
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
|
||||
-- battle screen already draws exactly this.
|
||||
function OverworldBattle.backPinned()
|
||||
if not OverworldBattle.enabled() then return false end
|
||||
return OverworldBattle.backSetting:get() and true or false
|
||||
end
|
||||
|
||||
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
|
||||
-- text box, rather than geometry standing out on the map.
|
||||
--
|
||||
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
|
||||
-- that holds the slot until "Go!" -- because that is the only pic this mod
|
||||
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
|
||||
-- tile whichever mode is on, and with the mode off the player's side is one
|
||||
-- too, so both of those keep the open bottom that lets the arena through a
|
||||
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
|
||||
-- behind its lowest row, so its bottom edge seals.
|
||||
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
|
||||
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
|
||||
-- it that way for the whole render -- during which the pic layer runs, and
|
||||
-- picImage asks this. A nil test passes a `false` straight through to the
|
||||
-- index below, and the error comes out of sideTexture into the pcall that
|
||||
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
|
||||
-- simply is not there.
|
||||
function OverworldBattle.pinnedPic(battle, img)
|
||||
if not (battle and img) then return false end
|
||||
if not OverworldBattle.backPinned() then return false end
|
||||
if img == battle.playerBackPic then return true end
|
||||
local player = battle.player
|
||||
return (player and img == player.sprite) and true or false
|
||||
end
|
||||
|
||||
-- ------- both mons face you
|
||||
--
|
||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||
@@ -81,6 +138,10 @@ end
|
||||
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
||||
-- exactly this, so no battle code has to be touched to get it.
|
||||
--
|
||||
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
|
||||
-- that mon is drawn in its own slot on the menu, seen from behind, and the
|
||||
-- front art would be it turned round to face the player it belongs to.
|
||||
--
|
||||
-- Answered BEFORE a battle exists, because the battler is built before the
|
||||
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
||||
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
||||
@@ -91,6 +152,7 @@ local staged = { mapId = nil, ok = false }
|
||||
|
||||
function OverworldBattle.wantsFront()
|
||||
if not OverworldBattle.enabled() then return false end
|
||||
if OverworldBattle.backPinned() then return false end
|
||||
if not Voxel3D.available() then return false end
|
||||
-- required here rather than through the file's own helper: this runs
|
||||
-- while a battler is being built, which is before that helper is defined
|
||||
@@ -140,6 +202,110 @@ OverworldBattle.HUD_RECT = {
|
||||
player = { 72, 56, 88, 40 },
|
||||
}
|
||||
|
||||
-- ------- the box at the bottom, on the same glass
|
||||
--
|
||||
-- The HUDs got frosted panels because black glyphs on grass are not readable.
|
||||
-- The battle's text box and its menu had the opposite problem and the same
|
||||
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
|
||||
-- the field's own colour when the field was white and is a sheet of paper laid
|
||||
-- over the bottom third of the diorama now that it is not.
|
||||
--
|
||||
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
|
||||
-- frosted glass and laid back down translucent, with the border and the text
|
||||
-- drawn over it unchanged, and the same brightness verdict flipping the ink
|
||||
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
|
||||
-- the engine draws inside the box is still the engine's own, in its own place.
|
||||
--
|
||||
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
|
||||
-- READ-ONLY duplicates of that function's own branches, the same kind of
|
||||
-- mirror hudLive is and for the same reason: there is no seam that reports "a
|
||||
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
|
||||
-- The worst a future engine change can do is frost a rectangle nothing lands
|
||||
-- on, or leave a box unfrosted -- never break a battle.
|
||||
--
|
||||
-- Each rect stops where the next one starts rather than overlapping it: two
|
||||
-- panels over the same pixels would frost it twice and leave a visible step
|
||||
-- along the seam.
|
||||
OverworldBattle.TEXT_RECT = {
|
||||
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
|
||||
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
|
||||
-- above the box above -- its last tile row sits inside that one
|
||||
moves = { 0, 64, 88, 32 },
|
||||
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
|
||||
mimic = { 0, 56, 128, 40 },
|
||||
}
|
||||
|
||||
function OverworldBattle.textRects(battle)
|
||||
if not battle or battle.blankForAskName then return {} end
|
||||
local r = OverworldBattle.TEXT_RECT
|
||||
local out = { box = r.box }
|
||||
if battle.phase == "moveSelect" then
|
||||
out.moves = r.moves
|
||||
elseif battle.phase == "mimicSelect" then
|
||||
out.mimic = r.mimic
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- the HUDs, out at the window's own edges
|
||||
--
|
||||
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
|
||||
-- whole of it. That left both HUD blocks huddled together in the middle of the
|
||||
-- frame with map showing on either side of them, which reads as a Game Boy
|
||||
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
|
||||
--
|
||||
-- So each block is snapped to its own side: the foe's to the left edge of the
|
||||
-- window, the player's to the right. Nothing about either block changes -- same
|
||||
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
|
||||
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
|
||||
-- of the GB screen there is nowhere to go and the snap is a no-op.
|
||||
--
|
||||
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
|
||||
-- canvas and everything outside it is clipped away. So the layer is rendered to
|
||||
-- a texture and composited into the WORLD image instead, which is the one
|
||||
-- surface in this mode that covers the whole window.
|
||||
|
||||
-- The rows each block is cut out of, full width. Generous on purpose:
|
||||
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
|
||||
-- further than the panel does, and the pokeball rows and the safari ball count
|
||||
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
|
||||
-- outside rows 0-96, and the two bands split that between them.
|
||||
OverworldBattle.HUD_BAND = {
|
||||
enemy = { 0, 0, 160, 48 },
|
||||
player = { 0, 48, 160, 48 },
|
||||
}
|
||||
|
||||
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
|
||||
-- glass is cut to, plus the x its band is blitted at.
|
||||
--
|
||||
-- The foe's panel starts at the window's left edge and the player's ends at the
|
||||
-- right one. The vertical is untouched, so both stay on the rows the GB put
|
||||
-- them on. A band's own origin sits outside the window by the panel's inset --
|
||||
-- the couple of pixels a HUD shake can push past the edge are clipped there,
|
||||
-- which is the whole cost of the snap and is invisible.
|
||||
function OverworldBattle.snapRects(shot)
|
||||
local s = shot.scale
|
||||
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
|
||||
local ex = -e[1] * s -- foe: panel's left edge to 0
|
||||
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
|
||||
local rects = {
|
||||
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
|
||||
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
|
||||
}
|
||||
return rects, { enemy = ex, player = px }
|
||||
end
|
||||
|
||||
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
|
||||
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
|
||||
-- where it always was -- but its glass is laid into the world image alongside
|
||||
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
|
||||
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
|
||||
local function toWorld(rect, shot)
|
||||
local s = shot.scale
|
||||
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
|
||||
rect[3] * s, rect[4] * s }
|
||||
end
|
||||
|
||||
-- ------- the live battle
|
||||
--
|
||||
-- nil when no overworld battle is running. Never more than one: battles do
|
||||
@@ -150,6 +316,13 @@ local function game()
|
||||
return require("src.core.Game")
|
||||
end
|
||||
|
||||
-- Whether this frame's HUDs went out to the window's edges instead of being
|
||||
-- drawn in the GB frame. False whenever the composite could not be made, which
|
||||
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
|
||||
local function snapped()
|
||||
return (session and session.snapped) and true or false
|
||||
end
|
||||
|
||||
-- Put the map's cast back. Both lists are handed back by identity, so
|
||||
-- anything that captured one before the battle still sees the same table.
|
||||
local function restoreCast()
|
||||
@@ -174,6 +347,36 @@ local function cullCast(state)
|
||||
state.ghosts = {}
|
||||
end
|
||||
|
||||
-- ------- one right battle layout
|
||||
--
|
||||
-- Everything this file composes is measured in the GB's own 160x144 frame: the
|
||||
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
|
||||
-- the frosted panels are cut to, and the full-frame white intercepted to let
|
||||
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
|
||||
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
|
||||
-- -- the mons would stand where no camera was solved for them, and the panels
|
||||
-- would land beside the HUDs they are supposed to be under.
|
||||
--
|
||||
-- So while a fight can be staged on the map there is one right answer, and it
|
||||
-- is SET rather than worked around. The engine reads the option live
|
||||
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
|
||||
-- top state for its surface the same way), so writing it here lands on the
|
||||
-- battle being pushed as well as every one after it.
|
||||
--
|
||||
-- This is the last line rather than the first: the OPTIONS menu takes the row
|
||||
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
|
||||
-- player is never offered a switch that gets reverted under them. What reaches
|
||||
-- here is a value that arrived some other way -- a save written before the mod
|
||||
-- was installed, the mod manager's own page, another mod.
|
||||
function OverworldBattle.forceOG(g)
|
||||
g = g or game()
|
||||
local opts = g and g.save and g.save.options
|
||||
if not opts or opts.battleLayout ~= "wide" then return false end
|
||||
opts.battleLayout = "og"
|
||||
if g.writeOptions then pcall(g.writeOptions, g) end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Stage a battle triggered from `state`, if this mode can. Returns true when
|
||||
-- a session started -- which is also the only case where anything visible
|
||||
-- changes, so a map with no room for an arena plays exactly the vanilla
|
||||
@@ -189,6 +392,10 @@ function OverworldBattle.begin(state, battle)
|
||||
state.player.surfing)
|
||||
if not (ok and arena) then return false end
|
||||
|
||||
-- the fight is staged from here on, so the layout it is composed for is not
|
||||
-- optional any more (see forceOG)
|
||||
OverworldBattle.forceOG()
|
||||
|
||||
session = { state = state, arena = arena, battle = battle, shot = nil,
|
||||
armed = false, token = 0 }
|
||||
cullCast(state)
|
||||
@@ -276,11 +483,13 @@ function OverworldBattle.update(dt)
|
||||
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
|
||||
-- guard, which retires a pipeline for the session.
|
||||
session.shot = nil
|
||||
session.snapped = false
|
||||
session.broken = true
|
||||
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
|
||||
.. "on the plain battle background", tostring(shot))
|
||||
return
|
||||
end
|
||||
session.snapped = false
|
||||
if shot and shot.canvas then
|
||||
-- the depth of field is measured off the two marks: the slab in focus is
|
||||
-- the one the mons are standing in, at whatever the drift has done to
|
||||
@@ -295,6 +504,20 @@ function OverworldBattle.update(dt)
|
||||
-- so a panel over a blurred far field is frosted from what is actually
|
||||
-- behind it
|
||||
pcall(BattleHud.build, shot.canvas)
|
||||
-- and then the HUDs go ON that backdrop, snapped out to the window's own
|
||||
-- edges (snapHUDs). Here rather than in the battle's draw for the same
|
||||
-- reason the scene is: it binds a canvas of its own. After the frost, so
|
||||
-- the glass is frosted from the world alone and never from the glyphs
|
||||
-- about to sit on it.
|
||||
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||
session.snapped = (okHud and up) and true or false
|
||||
-- once per battle, not once per frame: a driver that cannot do this cannot
|
||||
-- do it sixty times a second either, and the fallback is silent and fine
|
||||
if not okHud and not session.hudWarned then
|
||||
session.hudWarned = true
|
||||
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
|
||||
.. "in the battle frame this battle", tostring(up))
|
||||
end
|
||||
end
|
||||
session.shot = shot
|
||||
end
|
||||
@@ -366,6 +589,81 @@ local function withoutBackgroundFill(battle, fn)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- ------- the box, without its paper
|
||||
--
|
||||
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
|
||||
-- opaque slab the frosted panel underneath is there to replace. So for the
|
||||
-- length of one drawTextArea the white fills are dropped and everything else
|
||||
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
|
||||
-- always did, over the glass instead of over paper.
|
||||
--
|
||||
-- Every fill drawTextArea issues is one of those: the box's own, and the two
|
||||
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
|
||||
-- the border glyphs that hardware would have overwritten. Both are opaque
|
||||
-- white, both are paper, and both go.
|
||||
--
|
||||
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
|
||||
-- installed around a single call, removed on the way out including on error,
|
||||
-- never live outside a battle frame this mode is drawing.
|
||||
local function withoutBoxFill(battle, fn)
|
||||
local g = love.graphics
|
||||
local rectangle = g.rectangle
|
||||
g.rectangle = function(mode, ...)
|
||||
if mode == "fill" then
|
||||
local r, gr, b, a = g.getColor()
|
||||
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
|
||||
end
|
||||
return rectangle(mode, ...)
|
||||
end
|
||||
local ok, err = pcall(fn, battle)
|
||||
g.rectangle = rectangle
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- ------- the hour's light, on a pic that is not geometry
|
||||
--
|
||||
-- Everything standing in the arena goes through the voxel shader, and that
|
||||
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
|
||||
-- night it goes blue, and the two mons' cards go with it because they are
|
||||
-- drawn in the same pass as the ground they stand on.
|
||||
--
|
||||
-- A back pic pinned to the menu is not in that pass. It is the engine's own
|
||||
-- flat blit over the finished shot, so it arrived at noon while the world
|
||||
-- behind it was at midnight -- a mon lit by nothing in the frame.
|
||||
--
|
||||
-- So the tint is applied by hand, to that one draw. Every colour the pics
|
||||
-- layer sets is multiplied on its way past, which is the whole of it: the
|
||||
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
|
||||
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
|
||||
-- slide's fade and the blink's own colour all compose with it rather than
|
||||
-- being overwritten.
|
||||
--
|
||||
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
|
||||
-- standing under a tree is darker than the tint alone, and this pic has no
|
||||
-- position in the scene to be shadowed at. It carries the hour and not the
|
||||
-- weather, which is the part the eye reads.
|
||||
local function withTint(tint, fn, ...)
|
||||
if not tint then return fn(...) end
|
||||
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
|
||||
local gfx = love.graphics
|
||||
local setColor = gfx.setColor
|
||||
gfx.setColor = function(cr, cg, cb, ca, ...)
|
||||
if type(cr) == "table" then
|
||||
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
|
||||
cr[4] }, cg, ...)
|
||||
end
|
||||
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
|
||||
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
|
||||
end
|
||||
local ok, err = pcall(fn, ...)
|
||||
gfx.setColor = setColor
|
||||
-- the layer leaves whatever colour it last set, and that one is tinted;
|
||||
-- hand the next caller plain white rather than a dimmed one
|
||||
setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- ------- the mons, as textures for the 3D pass
|
||||
--
|
||||
-- The two Pokemon are not composited over the world any more: they are quads
|
||||
@@ -394,6 +692,7 @@ local texturing = nil
|
||||
|
||||
local texCanvas = {}
|
||||
local innerPics = nil -- captured by install()
|
||||
local innerHUDs = nil -- likewise, for the snapped HUD layer
|
||||
|
||||
local function texCanvasFor(side)
|
||||
local c = texCanvas[side]
|
||||
@@ -494,11 +793,19 @@ function OverworldBattle.flashing(battle)
|
||||
end
|
||||
|
||||
-- Both sides, or nil when neither has anything to show.
|
||||
--
|
||||
-- One side under BACK SPRITES: the player's mon is not standing on the map at all
|
||||
-- there, it is on the menu, so it has no card to be a texture for -- and
|
||||
-- nothing downstream has to know that. No billboard, and no shadow on the
|
||||
-- ground under a mon that is not on it.
|
||||
function OverworldBattle.textures(battle)
|
||||
if not battle then return nil end
|
||||
local out = {}
|
||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
||||
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
local okP, player = true, nil
|
||||
if not OverworldBattle.backPinned() then
|
||||
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
end
|
||||
out.enemy = okE and enemy or nil
|
||||
out.player = okP and player or nil
|
||||
if not (out.enemy or out.player) then return nil end
|
||||
@@ -549,11 +856,17 @@ function OverworldBattle.install()
|
||||
-- behind it. There is a world back there now, so they are filled here
|
||||
-- instead -- see BattlePics, which puts the paper back without touching
|
||||
-- the silhouette.
|
||||
--
|
||||
-- The pinned pic is told that its feet are on the box, which is what lets
|
||||
-- the pale-bodied back sprites be filled at all: their bellies leak out
|
||||
-- through an opening too wide to read as a drain, and only the box under
|
||||
-- them settles that it is not a hole. Passed the pre-bake image, because
|
||||
-- that is the one the battle holds a reference to.
|
||||
local innerPic = BattleState.picImage
|
||||
function BattleState:picImage(img)
|
||||
local out = innerPic(self, img)
|
||||
if not OverworldBattle.shot() then return out end
|
||||
return BattlePics.filled(out)
|
||||
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
||||
end
|
||||
|
||||
-- While a billboard texture is being rendered both pics are put in the same
|
||||
@@ -613,10 +926,44 @@ function OverworldBattle.install()
|
||||
-- before this screen is composited at all, so the flat pics layer has
|
||||
-- nothing left to do here. Skipped rather than left to draw underneath, or
|
||||
-- every Pokemon would appear twice: once on its tile and once in its slot.
|
||||
--
|
||||
-- Except under BACK SPRITES, where the player's side never became geometry and this
|
||||
-- layer is the only thing that draws it. The engine's own onlySide argument
|
||||
-- does the whole job: one call, the player's branches alone, in the slot and
|
||||
-- at the scale the GB always put them -- feet on the box, 2x, back view.
|
||||
innerPics = BattleState.drawPicsLayer
|
||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
if self.dramaticShapeShot then return end
|
||||
return innerPics(self, slide, sx, sy)
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
local shot = self.dramaticShapeShot
|
||||
if not shot then
|
||||
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
||||
end
|
||||
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
|
||||
-- under the hour's own light, like everything else in the frame -- see
|
||||
-- withTint, and the tint BattleScene hands over with the shot.
|
||||
--
|
||||
-- Except on the wavy path, where the pic is baked into the GRAYSCALE bg
|
||||
-- canvas for the zone pass to colour by region. That pass keys off the
|
||||
-- red channel, and a night tint pulls red down -- it would not darken
|
||||
-- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a
|
||||
-- second and the hour survives it fine.
|
||||
local tint = not self.grayPics and shot.tint or nil
|
||||
return withTint(tint, innerPics, self, slide, sx, sy, "player",
|
||||
skipMenuClip)
|
||||
end
|
||||
end
|
||||
|
||||
-- The battle's text box and its menus, over the frosted glass laid down for
|
||||
-- them rather than over their own white paper -- and their ink flipped with
|
||||
-- the HUD's when the ground under the frame is dark, by the same rule and
|
||||
-- off the same verdict.
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
local battle = self
|
||||
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
|
||||
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||
withoutBoxFill(battle, innerText)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Move animations are authored against the pics' fixed slots, and a single
|
||||
@@ -634,10 +981,13 @@ function OverworldBattle.install()
|
||||
-- mons' projected positions, less the midpoint of the slots they used to
|
||||
-- sit in. A hit still lands on the mon it is aimed at.
|
||||
local a = OverworldBattle.ANCHOR
|
||||
local dx = (shot.enemy[1] + shot.player[1]) / 2
|
||||
- (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + shot.player[2]) / 2
|
||||
- (a.enemy[2] + a.player[2]) / 2
|
||||
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
||||
-- contributes no movement at all and the pair's centre has gone half as
|
||||
-- far as the foe's mark did.
|
||||
local px, py = shot.player[1], shot.player[2]
|
||||
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
||||
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||
local ok, err = pcall(innerAnim, self, colorized)
|
||||
@@ -695,8 +1045,12 @@ function OverworldBattle.install()
|
||||
--
|
||||
-- The HP bar is untouched: it is drawn in its own greens and reds, and
|
||||
-- only an exactly-black set is remapped.
|
||||
local innerHUDs = BattleState.drawHUDs
|
||||
innerHUDs = BattleState.drawHUDs
|
||||
function BattleState:drawHUDs(slide)
|
||||
-- Normally the HUDs have already been drawn this frame, snapped out to the
|
||||
-- window's edges and composited into the world image (snapHUDs). Drawing
|
||||
-- them here as well would show each block twice, once in each place.
|
||||
if self.dramaticShapeShot and snapped() then return end
|
||||
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
|
||||
return innerHUDs(self, slide)
|
||||
end
|
||||
@@ -726,19 +1080,115 @@ function OverworldBattle.hudLive(battle, slide)
|
||||
return enemy and true or false, player and true or false
|
||||
end
|
||||
|
||||
-- Lay the frosted glass down under whichever HUD is about to draw, and
|
||||
-- record which way the glyphs have to flip.
|
||||
-- ------- the snapped composite
|
||||
--
|
||||
-- The engine's own HUD layer, rendered into a texture.
|
||||
--
|
||||
-- One thing is falsified for the render, and it is falsified because this layer
|
||||
-- never reaches the battle's zone pass -- it is composited into the world image,
|
||||
-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
|
||||
-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
|
||||
-- answered false, it tints its own greens and reds instead, exactly as it does
|
||||
-- on the flat path. The glyphs are pure black either way, which is what the
|
||||
-- flip in BattleHud.layerTexture is measured against.
|
||||
--
|
||||
-- Shadowed on the instance for this call only, the way drawZonePass shadows
|
||||
-- activeBgp: putting the field back to whatever it was (normally nil) lets the
|
||||
-- class method be found again.
|
||||
function OverworldBattle.hudTexture(battle, slide, dark)
|
||||
if not (innerHUDs and battle) then return nil end
|
||||
local had = rawget(battle, "colorMode")
|
||||
battle.colorMode = function() return false end
|
||||
local ok, layer = pcall(BattleHud.layerTexture,
|
||||
BattleScene.GB_W, BattleScene.GB_H, dark,
|
||||
function() innerHUDs(battle, slide) end)
|
||||
battle.colorMode = had
|
||||
return ok and layer or nil
|
||||
end
|
||||
|
||||
-- Draw both HUD blocks into the world image at the window's edges, each on its
|
||||
-- own frosted panel. Returns true when the frame's HUDs are up there and the
|
||||
-- in-frame draw must be skipped; false leaves the battle screen's own HUD
|
||||
-- exactly as it was before any of this existed.
|
||||
--
|
||||
-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
|
||||
-- carries more than the HUD: the pokeball rows of the intro and of an enemy
|
||||
-- faint, and the safari ball count, all draw in these rows and belong at the
|
||||
-- same edge as the block they share it with. The panels are the ones that
|
||||
-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
|
||||
function OverworldBattle.snapHUDs(battle, shot)
|
||||
if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
|
||||
return false
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local rects, bandX = OverworldBattle.snapRects(shot)
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
local live = {}
|
||||
if enemy then live.enemy = rects.enemy end
|
||||
if player then live.player = rects.player end
|
||||
-- and the text box's own glass, on the same pass. It stays in the middle of
|
||||
-- the frame where the engine draws it -- only the HUDs were snapped out --
|
||||
-- so its GB rect is mapped into the letterbox rather than to an edge.
|
||||
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||
live[key] = toWorld(rect, shot)
|
||||
end
|
||||
-- measured under the SNAPPED rects: the panels are over whatever the world
|
||||
-- shows at the window's edges now, which is not what was behind them in the
|
||||
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
|
||||
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
|
||||
-- corner and black ones on the menu reads as a bug rather than as adaptation.
|
||||
local dark = BattleHud.verdict(live, shot, true)
|
||||
-- the box's own ink is flipped where the engine draws it, in the GB frame,
|
||||
-- so the answer has to outlive this function (see drawHudPanels)
|
||||
if session then session.dark = dark end
|
||||
local layer = OverworldBattle.hudTexture(battle, slide, dark)
|
||||
if not layer then return false end
|
||||
|
||||
local g = love.graphics
|
||||
local prevCanvas = g.getCanvas()
|
||||
local prevBlend, prevAlpha = g.getBlendMode()
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(shot.canvas)
|
||||
g.setBlendMode("alpha")
|
||||
for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
for side, band in pairs(OverworldBattle.HUD_BAND) do
|
||||
local quad = g.newQuad(band[1], band[2], band[3], band[4],
|
||||
BattleScene.GB_W, BattleScene.GB_H)
|
||||
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
|
||||
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
|
||||
end
|
||||
end)
|
||||
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
||||
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Lay the frosted glass down under whichever HUD and box are about to draw,
|
||||
-- and record which way the glyphs have to flip.
|
||||
--
|
||||
-- The panels are the fallback path only: normally the HUDs are snapped out to
|
||||
-- the window's edges and their glass, and the box's, went into the world image
|
||||
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
|
||||
-- drawn here, in the GB frame, whichever path laid the glass under it.
|
||||
function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
battle.dramaticShapeDark = nil
|
||||
if not shot then return end
|
||||
if snapped() then
|
||||
battle.dramaticShapeDark = session and session.dark or nil
|
||||
return
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
if not (enemy or player) then return end
|
||||
local rect = OverworldBattle.HUD_RECT
|
||||
local live = {}
|
||||
if enemy then live.enemy = rect.enemy end
|
||||
if player then live.player = rect.player end
|
||||
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
|
||||
if not next(live) then return end
|
||||
local dark = BattleHud.verdict(live, shot)
|
||||
battle.dramaticShapeDark = dark
|
||||
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
-- Voxel world mode: the instrumentation core.
|
||||
--
|
||||
-- Ships DARK. Every entry point is one boolean test away from doing
|
||||
-- nothing, and the boolean is false unless a run explicitly asks for
|
||||
-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the
|
||||
-- save directory for a device that has no environment to set). A mod that
|
||||
-- measures itself in every player's session is a mod that costs every
|
||||
-- player the measurement, so the default has to be off and the off path
|
||||
-- has to be free.
|
||||
--
|
||||
-- What it measures, and why those three things:
|
||||
--
|
||||
-- * LABELS -- named spans (a bake, a mesh build, a shader compile),
|
||||
-- accumulated as {n, total, max}. `max` is the one that matters: a
|
||||
-- bake that costs 40ms ONCE is a visible hitch, and an average hides
|
||||
-- it completely.
|
||||
-- * FRAMES -- a ring of the last N whole-frame times, stamped once per
|
||||
-- rendered frame. Frame time is the only number the player actually
|
||||
-- experiences; every label total is a hypothesis about which frames.
|
||||
-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas
|
||||
-- rebakes). Cheaper than a span when the question is "how often",
|
||||
-- not "how long".
|
||||
--
|
||||
-- Spans are wall time, and on a GPU that means submission time, not
|
||||
-- completion time -- the driver is free to finish the work later. So a
|
||||
-- GPU-side saving shows up in the FRAME numbers rather than in the label
|
||||
-- for the pass that caused it, and both are reported.
|
||||
|
||||
local Perf = {}
|
||||
|
||||
local clock = (love and love.timer and love.timer.getTime) or os.clock
|
||||
|
||||
-- Read through pcall: the loader's sandbox does not hand a mod `os`, and
|
||||
-- instrumentation must never be the reason the mod fails to load. Same
|
||||
-- shape as OverworldBattle's DS_BATTLE_DEBUG probe.
|
||||
local function envFlag(name)
|
||||
local ok, value = pcall(function() return os.getenv(name) end)
|
||||
if not ok then return nil end
|
||||
if value == nil or value == "" or value == "0" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function flagFile()
|
||||
if not (love and love.filesystem and love.filesystem.getInfo) then
|
||||
return false
|
||||
end
|
||||
local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag")
|
||||
return ok and info ~= nil
|
||||
end
|
||||
|
||||
Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile()
|
||||
|
||||
Perf.labels = {} -- label -> { n, total, max }
|
||||
Perf.order = {} -- insertion order, so a report reads chronologically
|
||||
Perf.counters = {} -- name -> integer
|
||||
Perf.frames = {} -- ring of frame times, seconds
|
||||
Perf.frameCount = 0
|
||||
Perf.RING = 4096
|
||||
|
||||
-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark
|
||||
-- names the phase it is driving; every frame and every label span
|
||||
-- recorded while that name is set is attributed to it, which is what
|
||||
-- turns "the walk was slow" into "the walk was slow ONLY on the frames
|
||||
-- right after ROUTE_1 came into view".
|
||||
Perf.segment = nil
|
||||
Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} }
|
||||
|
||||
local function segmentEntry()
|
||||
local name = Perf.segment
|
||||
if not name then return nil end
|
||||
local s = Perf.segments[name]
|
||||
if not s then
|
||||
s = { name = name, frames = {}, labels = {}, order = {} }
|
||||
Perf.segments[name] = s
|
||||
Perf.segments[#Perf.segments + 1] = s -- array half preserves order
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
function Perf.setSegment(name)
|
||||
Perf.segment = name
|
||||
if name then segmentEntry() end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- spans
|
||||
--
|
||||
-- Call shape at the measured site:
|
||||
--
|
||||
-- local t0 = Perf.now()
|
||||
-- ... the work ...
|
||||
-- Perf.add("TerrainAtlas.staticAtlas", t0)
|
||||
--
|
||||
-- When disabled, now() returns nil and add() returns on the nil -- two
|
||||
-- function calls and a branch, no table touched, no string built. Sites
|
||||
-- that would run thousands of times a frame (per draw call, per vertex)
|
||||
-- are still too hot for that and are deliberately NOT instrumented; the
|
||||
-- frame ring covers them in aggregate.
|
||||
|
||||
function Perf.now()
|
||||
if not Perf.enabled then return nil end
|
||||
return clock()
|
||||
end
|
||||
|
||||
local function bump(store, order, label, dt)
|
||||
local s = store[label]
|
||||
if not s then
|
||||
s = { n = 0, total = 0, max = 0 }
|
||||
store[label] = s
|
||||
order[#order + 1] = label
|
||||
end
|
||||
s.n = s.n + 1
|
||||
s.total = s.total + dt
|
||||
if dt > s.max then s.max = dt end
|
||||
end
|
||||
|
||||
function Perf.add(label, t0)
|
||||
if t0 == nil then return end
|
||||
local dt = clock() - t0
|
||||
bump(Perf.labels, Perf.order, label, dt)
|
||||
local seg = segmentEntry()
|
||||
if seg then bump(seg.labels, seg.order, label, dt) end
|
||||
end
|
||||
|
||||
-- Wrap a function in a table, in place. Used by drivers to instrument
|
||||
-- module internals they do not own; the mod's own code calls now()/add()
|
||||
-- directly so the label is visible at the site.
|
||||
function Perf.wrap(tbl, name, label)
|
||||
local orig = tbl and tbl[name]
|
||||
if not orig then return false end
|
||||
tbl[name] = function(...)
|
||||
if not Perf.enabled then return orig(...) end
|
||||
local t0 = clock()
|
||||
local a, b, c, d = orig(...)
|
||||
Perf.add(label or name, t0)
|
||||
return a, b, c, d
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- counters
|
||||
|
||||
function Perf.count(name, by)
|
||||
if not Perf.enabled then return end
|
||||
Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1)
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- frames
|
||||
--
|
||||
-- Called once per RENDERED frame (the endFrame seam), not once per
|
||||
-- logic update: a scripted run can step the game many times per render,
|
||||
-- and a frame the player never saw cannot have hitched for them.
|
||||
|
||||
local lastFrame = nil
|
||||
|
||||
function Perf.frame()
|
||||
if not Perf.enabled then return end
|
||||
local t = clock()
|
||||
if lastFrame then
|
||||
local dt = t - lastFrame
|
||||
local n = Perf.frameCount + 1
|
||||
Perf.frameCount = n
|
||||
Perf.frames[(n - 1) % Perf.RING + 1] = dt
|
||||
local seg = segmentEntry()
|
||||
if seg then seg.frames[#seg.frames + 1] = dt end
|
||||
end
|
||||
lastFrame = t
|
||||
end
|
||||
|
||||
-- Discard the pending frame stamp: after a long blocking operation the
|
||||
-- next frame delta would include it and libel the renderer.
|
||||
function Perf.resync()
|
||||
lastFrame = Perf.enabled and clock() or nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ reporting
|
||||
|
||||
local function percentile(sorted, p)
|
||||
local n = #sorted
|
||||
if n == 0 then return 0 end
|
||||
local i = math.ceil(p * n)
|
||||
if i < 1 then i = 1 end
|
||||
if i > n then i = n end
|
||||
return sorted[i]
|
||||
end
|
||||
|
||||
-- Frame statistics in MILLISECONDS. p95/p99 rather than the average
|
||||
-- because smoothness is a tail property: a run that averages 9ms and
|
||||
-- spikes to 60ms four times reads as stuttering, and its average reads
|
||||
-- as fine.
|
||||
function Perf.frameStats(list)
|
||||
local src = list or Perf.frames
|
||||
local sorted = {}
|
||||
for i = 1, #src do sorted[i] = src[i] * 1000 end
|
||||
table.sort(sorted)
|
||||
local n = #sorted
|
||||
local total = 0
|
||||
for i = 1, n do total = total + sorted[i] end
|
||||
local over16, over33 = 0, 0
|
||||
for i = 1, n do
|
||||
if sorted[i] > 16.7 then over16 = over16 + 1 end
|
||||
if sorted[i] > 33.3 then over33 = over33 + 1 end
|
||||
end
|
||||
return {
|
||||
n = n,
|
||||
avg = n > 0 and total / n or 0,
|
||||
p50 = percentile(sorted, 0.50),
|
||||
p95 = percentile(sorted, 0.95),
|
||||
p99 = percentile(sorted, 0.99),
|
||||
worst = n > 0 and sorted[n] or 0,
|
||||
over16 = over16,
|
||||
over33 = over33,
|
||||
}
|
||||
end
|
||||
|
||||
function Perf.reset()
|
||||
Perf.labels, Perf.order = {}, {}
|
||||
Perf.counters = {}
|
||||
Perf.frames, Perf.frameCount = {}, 0
|
||||
Perf.segments = {}
|
||||
Perf.segment = nil
|
||||
lastFrame = nil
|
||||
end
|
||||
|
||||
local function sortedLabels(store, order)
|
||||
local out = {}
|
||||
for _, lbl in ipairs(order) do out[#out + 1] = lbl end
|
||||
table.sort(out, function(a, b) return store[a].total > store[b].total end)
|
||||
return out
|
||||
end
|
||||
|
||||
function Perf.printReport(title)
|
||||
print(("[perf] ==== %s ===="):format(tostring(title or "report")))
|
||||
local f = Perf.frameStats()
|
||||
print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d")
|
||||
:format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33))
|
||||
for _, seg in ipairs(Perf.segments) do
|
||||
local s = Perf.frameStats(seg.frames)
|
||||
print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d")
|
||||
:format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33))
|
||||
end
|
||||
print("[perf] ---- labels (ms, sorted by total) ----")
|
||||
for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do
|
||||
local s = Perf.labels[lbl]
|
||||
print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f")
|
||||
:format(lbl, s.n, s.total * 1000, s.max * 1000))
|
||||
end
|
||||
local names = {}
|
||||
for k in pairs(Perf.counters) do names[#names + 1] = k end
|
||||
table.sort(names)
|
||||
if #names > 0 then print("[perf] ---- counters ----") end
|
||||
for _, k in ipairs(names) do
|
||||
print(("[perf] %-46s %d"):format(k, Perf.counters[k]))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ json
|
||||
--
|
||||
-- Hand-rolled rather than pulled from the engine: the report has to be
|
||||
-- readable by a diff tool between two runs, and that means stable key
|
||||
-- ORDER, which a generic serializer does not promise.
|
||||
|
||||
local function q(s)
|
||||
return '"' .. tostring(s):gsub('[%c"\\]', function(c)
|
||||
if c == '"' then return '\\"' end
|
||||
if c == "\\" then return "\\\\" end
|
||||
return ("\\u%04x"):format(c:byte())
|
||||
end) .. '"'
|
||||
end
|
||||
|
||||
local function num(x)
|
||||
return ("%.4f"):format(x)
|
||||
end
|
||||
|
||||
local function statsJson(f)
|
||||
return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}")
|
||||
:format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99),
|
||||
num(f.worst), f.over16, f.over33)
|
||||
end
|
||||
|
||||
local function labelsJson(store, order)
|
||||
local parts = {}
|
||||
for _, lbl in ipairs(sortedLabels(store, order)) do
|
||||
local s = store[lbl]
|
||||
parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}")
|
||||
:format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000))
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
|
||||
function Perf.toJson(meta)
|
||||
local parts = {}
|
||||
parts[#parts + 1] = "{"
|
||||
parts[#parts + 1] = "\"meta\":{"
|
||||
local m = {}
|
||||
for k, v in pairs(meta or {}) do
|
||||
m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v))
|
||||
end
|
||||
table.sort(m)
|
||||
parts[#parts + 1] = table.concat(m, ",") .. "},"
|
||||
parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. ","
|
||||
parts[#parts + 1] = "\"segments\":{"
|
||||
local segs = {}
|
||||
for _, seg in ipairs(Perf.segments) do
|
||||
segs[#segs + 1] = q(seg.name) .. ":{\"frames\":"
|
||||
.. statsJson(Perf.frameStats(seg.frames))
|
||||
.. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}"
|
||||
end
|
||||
parts[#parts + 1] = table.concat(segs, ",") .. "},"
|
||||
parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. ","
|
||||
local cs = {}
|
||||
for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end
|
||||
table.sort(cs)
|
||||
parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}"
|
||||
parts[#parts + 1] = "}"
|
||||
return table.concat(parts, "")
|
||||
end
|
||||
|
||||
-- Written through love.filesystem (the save directory) rather than io:
|
||||
-- a driver run and an Android session both have one, and neither is
|
||||
-- guaranteed a writable working directory.
|
||||
function Perf.write(name, meta)
|
||||
local body = Perf.toJson(meta)
|
||||
if love and love.filesystem then
|
||||
pcall(love.filesystem.createDirectory, "ds_bench")
|
||||
local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body)
|
||||
if ok then
|
||||
print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory())
|
||||
.. "/ds_bench/" .. name .. ".json")
|
||||
return true
|
||||
end
|
||||
end
|
||||
print("[perf] JSON " .. name .. ": " .. body)
|
||||
return false
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- draw stats
|
||||
--
|
||||
-- love.graphics.getStats() resets per frame, so it is only meaningful
|
||||
-- read at the END of a frame -- which is where Perf.frame() runs.
|
||||
|
||||
function Perf.drawStats()
|
||||
if not (love and love.graphics and love.graphics.getStats) then return end
|
||||
local s = love.graphics.getStats()
|
||||
Perf.count("stat.drawcalls", s.drawcalls or 0)
|
||||
Perf.count("stat.canvasswitches", s.canvasswitches or 0)
|
||||
Perf.count("stat.shaderswitches", s.shaderswitches or 0)
|
||||
Perf.count("stat.frames", 1)
|
||||
Perf.texturememory = s.texturememory
|
||||
Perf.canvases = s.canvases
|
||||
Perf.images = s.images
|
||||
end
|
||||
|
||||
return Perf
|
||||
+118
-4
@@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160
|
||||
-- surface shadows itself in a moire of acne; too much and a shadow detaches
|
||||
-- from the foot of what casts it. The frustum is ~400 world pixels deep and
|
||||
-- the packed depth resolves under 0.01 of one, so there is room.
|
||||
ShadowMap.BIAS = 1.0
|
||||
--
|
||||
-- It cannot be ONE number, because what the comparison has to forgive is
|
||||
-- not fixed: the map stores one depth for a whole texel, so a lit surface
|
||||
-- reads its own depth wrong by however far it RAMPS across that texel --
|
||||
-- the texel's world size times the surface's slope in the light's frame.
|
||||
-- The texel swings from a third of a world pixel at the closest zoom to
|
||||
-- well over one at a maximised window on the widest, so a constant bias is
|
||||
-- generous at one end of the ladder and short at the other. Short shows up
|
||||
-- as diagonal bands of acne across big lit surfaces -- diagonal because
|
||||
-- the moire runs along neither the world grid nor the screen's, but along
|
||||
-- the depth ramp in the sun's own frame, and the sun sits southeast.
|
||||
--
|
||||
-- So: a floor for what does not scale (the packed depth's quantisation,
|
||||
-- and the two passes reaching the same world point by different matrices),
|
||||
-- plus a term in texels for what does.
|
||||
ShadowMap.BIAS = 0.5
|
||||
|
||||
-- World pixels of slack per world pixel of texel, for the steepest LIT
|
||||
-- surface here: a roof pitched 45 degrees and turned away from the sun,
|
||||
-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of
|
||||
-- the light frame's two axes (a vertical wall, by comparison, manages 1.7,
|
||||
-- flat ground 0.7, and anything steeper than that roof has its back to the
|
||||
-- sun and never reads the map at all). The 2x2 filter's taps sit half a
|
||||
-- texel out on both axes at once, so the worst a tap can disagree by is
|
||||
-- half the ramp along each -- which is where the halving that turns 6.2
|
||||
-- into 3.1 comes from, and why it is the SUM of the two components rather
|
||||
-- than their magnitude.
|
||||
--
|
||||
-- Measured against the artefact rather than trusted: the probe
|
||||
-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit
|
||||
-- surfaces, and the banding stops at slack ~2.4 world px on the widest
|
||||
-- rung -- where this lands 3.1 * 0.83 + 0.5.
|
||||
ShadowMap.SLOPE = 3.1
|
||||
|
||||
-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel.
|
||||
-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth
|
||||
-- the map actually stores.
|
||||
ShadowMap.slack = ShadowMap.BIAS
|
||||
|
||||
local SHADER = [[
|
||||
varying float vDepth;
|
||||
@@ -93,17 +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
|
||||
@@ -316,9 +359,58 @@ local function fit(cx, cy, vw, vh)
|
||||
-- what the frustum ended up covering, for probes: the lateral extent in
|
||||
-- world pixels divided by RES is how fine a shadow edge can land
|
||||
ShadowMap.extent = { r - l, t - b, far - near }
|
||||
-- the slack the comparison needs, against the coarser of the two texel
|
||||
-- axes (the box is asymmetric, and one number has to cover both)
|
||||
ShadowMap.slack = ShadowMap.BIAS
|
||||
+ ShadowMap.SLOPE * math.max(w, h) / res
|
||||
-- the stored depth spans the frustum, so a world-pixel bias is that
|
||||
-- fraction of it
|
||||
ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near)
|
||||
ShadowMap.bias = ShadowMap.slack / math.max(1, far - near)
|
||||
end
|
||||
|
||||
-- How much of the compare's forgiveness a snugged caster takes back, 0..1.
|
||||
-- Short of 1 on purpose: at exactly 1 the card's own fragments compare
|
||||
-- against their own stored depth on a float-equality knife edge and can
|
||||
-- speckle. The tenth left over is dozens of times the packed depth's
|
||||
-- quantization -- ample for that -- and leaves the contact gap around a
|
||||
-- quarter of a world pixel at any sun, which no zoom resolves.
|
||||
ShadowMap.SNUG = 0.9
|
||||
|
||||
-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is
|
||||
-- drawn into the map.
|
||||
--
|
||||
-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term)
|
||||
-- so lit surfaces do not acne against their own texels -- but that same
|
||||
-- forgiveness is what lets the ground right next to a standing figure read
|
||||
-- as lit: a receiver within `slack` of its blocker along the ray passes the
|
||||
-- test, so the first stretch of every shadow is forgiven away and on screen
|
||||
-- it starts that far from the feet, further the lower the sun. The classic
|
||||
-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at
|
||||
-- a day/night golden hour or under the moon.
|
||||
--
|
||||
-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow
|
||||
-- falls -- every point stays on the same light ray -- but moving it toward
|
||||
-- the sun stores it SHALLOWER, so a ground point right at the foot is
|
||||
-- already `slack` deeper than the stored blocker and fails the lit test:
|
||||
-- the root lands back under the feet. Nothing else is touched -- no
|
||||
-- terrain moved, so the acne margin the slack exists for is intact where
|
||||
-- it matters. For sprite cards and other thin stand-ins only.
|
||||
--
|
||||
-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same
|
||||
-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`).
|
||||
-- Stored and lookup then agree exactly, as they did before snugging, and
|
||||
-- the compare keeps its full acne margin. A caster stored snugged but read
|
||||
-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal
|
||||
-- moire bands crawling across the card.
|
||||
--
|
||||
-- Valid between begin() and the next begin(): `slack` and the sun hold
|
||||
-- still between redraws of the map, so a lit frame that reuses last
|
||||
-- frame's map computes the same displacement it was stored with.
|
||||
function ShadowMap.snug(model)
|
||||
local f = sunDir()
|
||||
local s = -ShadowMap.slack * ShadowMap.SNUG
|
||||
return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s),
|
||||
model or IDENTITY)
|
||||
end
|
||||
|
||||
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
|
||||
@@ -354,6 +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
|
||||
@@ -362,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()
|
||||
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
-- The sky, generated rather than shipped.
|
||||
--
|
||||
-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void
|
||||
-- behind it is sky rather than a black plate: at 75 degrees the horizon is
|
||||
-- genuinely in frame and the bands run down to meet it, and at the steeper rungs
|
||||
-- the void that shows is the ground running out past the map edge, which gets
|
||||
-- the same sky above the same haze. A battle's placed camera keeps the flat fill
|
||||
-- it has always had -- its horizon is above the frame and its look is not this
|
||||
-- rung's to change.
|
||||
--
|
||||
-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat
|
||||
-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band
|
||||
-- dithered into the bottom of each one. Alternating two colours on a pixel grid
|
||||
-- is how a machine with four colours to a palette got a fifth, sixth and seventh
|
||||
-- out of them, and it is what keeps four bands reading as a gradient rather than
|
||||
-- as four stripes. No clouds, nothing moving.
|
||||
--
|
||||
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
|
||||
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
|
||||
-- back up: one full-region rectangle through a shader that answers every pixel
|
||||
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
|
||||
-- displayed at, so there is nothing for a filter to soften and nothing to go
|
||||
-- stale when the window or the zoom changes. The shader does bind one texture,
|
||||
-- but it is a palette rather than an image -- the bands, one texel each, sampled
|
||||
-- nearest (see rampFor, and why it is not a uniform array).
|
||||
--
|
||||
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
|
||||
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
|
||||
-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band
|
||||
-- edges land on the same grid the world's own texels do, and a ZOOM keypress is
|
||||
-- reflected in the frame that follows it rather than whenever something else
|
||||
-- happened to rebuild.
|
||||
--
|
||||
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
|
||||
-- is shade order: a display mode transforms a four-colour palette by replacing it
|
||||
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
|
||||
-- written light to dark. So the sky reads the list backwards -- deepest shade
|
||||
-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
|
||||
-- nothing.
|
||||
--
|
||||
-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
|
||||
-- (four phase palettes, blended along the clock and re-quantised to the
|
||||
-- lattice), and this file paints whatever the clock says: blue at noon, gold
|
||||
-- and violet through the twilights -- warmed further around the low sun by a
|
||||
-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
|
||||
-- hang here too: cell-art discs on the same grid as the dither, scissored to
|
||||
-- the sky's own region so a setting body slips below the horizon point and is
|
||||
-- gone, never wandering under the map.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local DayNight = V.require("DayNight")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Sky = {}
|
||||
|
||||
-- The most bands a phase palette may paint with. Eight leaves headroom over
|
||||
-- DayNight's six-band ones without paying for more; the ramp the shader reads
|
||||
-- them from is built at the width actually used, so the cap costs nothing.
|
||||
Sky.MAX_BANDS = 8
|
||||
|
||||
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
|
||||
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
|
||||
-- leaves the top of each band flat -- a band dithered all the way through reads
|
||||
-- as one averaged colour instead of as a step with a soft bottom edge.
|
||||
Sky.DITHER = true
|
||||
Sky.DITHER_START = 0.6
|
||||
|
||||
-- How much of the frame the bands cover when the horizon is NOT in it, as a
|
||||
-- fraction of the canvas height.
|
||||
--
|
||||
-- At the steeper rungs the camera looks down far enough that the ground plane's
|
||||
-- vanishing line is above the top edge -- there is no horizon to hang the pale
|
||||
-- end on, but there is still void up there where the map runs out, and it should
|
||||
-- read as sky. So the bands take the same slice of the frame the top rung's own
|
||||
-- horizon gives them, which keeps the sky looking like one sky across the whole
|
||||
-- ladder instead of changing character rung by rung.
|
||||
Sky.SPAN = 0.23
|
||||
|
||||
-- ------- the bands
|
||||
--
|
||||
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
|
||||
--
|
||||
-- Memoised, because this runs once a frame and the answer only moves when the
|
||||
-- mode does.
|
||||
local cache = { bands = nil, key = {}, ramp = nil }
|
||||
|
||||
function Sky.bands()
|
||||
local pal = DayNight.palette()
|
||||
local shades = PaletteFX.effectiveColors(pal) or pal
|
||||
local n = math.min(#shades, #pal, Sky.MAX_BANDS)
|
||||
local key, k = cache.key, 0
|
||||
local same = cache.bands ~= nil and #cache.bands == n
|
||||
for i = 1, n do
|
||||
local c = shades[i]
|
||||
for ch = 1, 3 do
|
||||
k = k + 1
|
||||
if key[k] ~= c[ch] then same = false end
|
||||
key[k] = c[ch]
|
||||
end
|
||||
end
|
||||
if same then return cache.bands end
|
||||
|
||||
-- the ramp is these bands as a texture (see rampFor); a new list is a new
|
||||
-- ramp, and the old one is nothing's to keep
|
||||
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||
cache.ramp, cache.rampFor = nil, nil
|
||||
|
||||
local bands = {}
|
||||
for i = 1, n do
|
||||
-- backwards: the palette's darkest rung is the top band
|
||||
local c = shades[n - i + 1]
|
||||
bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 }
|
||||
end
|
||||
cache.bands = bands
|
||||
return bands
|
||||
end
|
||||
|
||||
-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
|
||||
-- bottom edge and the right flat fill for any outdoor void that wants to
|
||||
-- match the clock without painting bands (the battle arena's backdrop).
|
||||
function Sky.haze()
|
||||
local bands = Sky.bands()
|
||||
return bands and bands[#bands] or nil
|
||||
end
|
||||
|
||||
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
|
||||
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
|
||||
-- making it the bottom band's own colour means the haze below the sky and the
|
||||
-- bottom of the sky are one colour -- the join has no seam, and a frame that
|
||||
-- cannot paint the bands is a hazy sky rather than a wrong one.
|
||||
--
|
||||
-- Mutates the descriptor, which is a fresh table per frame from its caller.
|
||||
function Sky.dress(sky)
|
||||
local bands = Sky.bands()
|
||||
local haze = bands and bands[#bands]
|
||||
if not (sky and haze) then return sky end
|
||||
sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3]
|
||||
sky.bands = bands
|
||||
return sky
|
||||
end
|
||||
|
||||
-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon
|
||||
-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil
|
||||
-- when there is no room for any of it.
|
||||
function Sky.region(h, horizonY)
|
||||
if not (h and h > 0) then return nil end
|
||||
local edge = horizonY
|
||||
if not (edge and edge > 0) then edge = h * Sky.SPAN end
|
||||
edge = math.min(edge, h)
|
||||
if edge < 1 then return nil end
|
||||
return edge
|
||||
end
|
||||
|
||||
-- ------- the pass
|
||||
--
|
||||
-- One rectangle, one shader. Every pixel answers for itself from its canvas
|
||||
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
|
||||
-- -- there is no image being scaled and so nothing to be soft. The one texture
|
||||
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
|
||||
-- sampled nearest, one lookup per pixel (see rampFor).
|
||||
--
|
||||
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
|
||||
-- by is the top of its own cell row, so a whole cell row is one colour and every
|
||||
-- edge in the sky lands on the diorama's pixel grid.
|
||||
local SHADER_SRC = [[
|
||||
uniform Image ramp; // the bands, one texel each, top of the sky first
|
||||
uniform float count; // how many texels wide that ramp is
|
||||
uniform float edge; // the sky's bottom, in canvas pixels
|
||||
uniform float cell; // the diorama's pixel size, in canvas pixels
|
||||
uniform float start; // where the checker begins inside a band
|
||||
uniform float alpha;
|
||||
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
|
||||
uniform vec2 glowPos; // the sun disc, in canvas pixels
|
||||
uniform float glowInvR; // 1 / the glow's reach
|
||||
uniform vec3 glowColor;
|
||||
|
||||
// Band `i`, read from its own texel centre. The index is clamped rather than
|
||||
// trusted: `pos` below can land exactly on `count` when the arithmetic is
|
||||
// carried at mediump -- which is the fragment default on GLSL ES -- and a
|
||||
// sample past the last band must be the last band, not whatever is off the
|
||||
// end of the image.
|
||||
vec3 bandAt(float i) {
|
||||
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float row = floor(sc.y / cell) * cell; // top of this cell row
|
||||
float pos = min(row / max(edge, 1.0), 1.0) * count;
|
||||
float base = min(floor(pos), count - 1.0);
|
||||
vec3 c = bandAt(base);
|
||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||
if (base < count - 1.0 && (pos - base) > start) {
|
||||
if (parity < 0.5) { c = bandAt(base + 1.0); }
|
||||
}
|
||||
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
||||
// and checker-dithered between them -- the same 8-bit move as the bands,
|
||||
// so the glow reads as painted light rather than as a smooth airbrush --
|
||||
// and measured cell-to-cell, so its rings ride the diorama's own grid.
|
||||
if (glowAmt > 0.0) {
|
||||
vec2 cc = (floor(sc / cell) + 0.5) * cell;
|
||||
float d = length(cc - glowPos) * glowInvR;
|
||||
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
|
||||
float lvl = floor(g * 4.0);
|
||||
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
|
||||
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
|
||||
}
|
||||
return vec4(c, alpha);
|
||||
}
|
||||
]]
|
||||
|
||||
-- ------- the ramp
|
||||
--
|
||||
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
|
||||
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
|
||||
-- read through a loop counter. On desktop GL that is as portable as it looks.
|
||||
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
|
||||
-- strip running from partway down the gradient to the horizon point, with the
|
||||
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
|
||||
-- palest band again, but delivered by love.graphics.clear instead of by the
|
||||
-- array) landing in exactly the right colour. Same colour, two routes, one of
|
||||
-- them black: the fault was the array, not the palette.
|
||||
--
|
||||
-- Which of the ES failure modes it was hardly matters -- a driver that
|
||||
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
|
||||
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
|
||||
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
|
||||
-- because they all have the same shape: slots past the first few read as zero,
|
||||
-- and zero is black.
|
||||
--
|
||||
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
|
||||
-- there is no array to index and no budget to overrun, and a texel that does
|
||||
-- not exist cannot read as black because the image is built at exactly the
|
||||
-- width the shader divides by. Nearest and clamped, so a sample lands on one
|
||||
-- band's own colour and an out-of-range one lands on the end band rather than
|
||||
-- on nothing.
|
||||
--
|
||||
-- Rebuilt only when the bands move, which is when the clock or the display
|
||||
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
|
||||
local function rampFor(bands)
|
||||
if cache.ramp and cache.rampFor == bands then return cache.ramp end
|
||||
if not (love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then return nil end
|
||||
local n = #bands
|
||||
if n < 1 then return nil end
|
||||
local ok, data = pcall(love.image.newImageData, n, 1)
|
||||
if not (ok and data) then return nil end
|
||||
for i = 1, n do
|
||||
local c = bands[i]
|
||||
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
|
||||
end
|
||||
local built, img = pcall(love.graphics.newImage, data)
|
||||
if not (built and img) then return nil end
|
||||
-- nearest: a band is a flat colour, not something to interpolate between.
|
||||
-- clamp: the shader clamps its index too, so this is the second of two
|
||||
-- guards against ever sampling off the end -- and it returns the edge band.
|
||||
pcall(img.setFilter, img, "nearest", "nearest")
|
||||
pcall(img.setWrap, img, "clamp", "clamp")
|
||||
cache.ramp, cache.rampFor = img, bands
|
||||
return img
|
||||
end
|
||||
|
||||
Sky._rampFor = rampFor -- named for the suite
|
||||
|
||||
-- The band ramp for the CURRENT bands, plus how many texels wide it is --
|
||||
-- for a pass that wants to read the same sky this one paints. The water's
|
||||
-- reflection is the one caller: it looks the reflected direction up on this
|
||||
-- very ramp, so the sky on the lake and the sky over it are one palette,
|
||||
-- through one display-mode transform, off one clock.
|
||||
--
|
||||
-- nil where the ramp could not be built, which is exactly when Sky.paint
|
||||
-- falls back to flat bands -- so a driver that loses the gradient loses the
|
||||
-- reflected gradient with it rather than showing two different skies.
|
||||
function Sky.ramp()
|
||||
local bands = Sky.bands()
|
||||
if not (bands and bands[1]) then return nil end
|
||||
local img = rampFor(bands)
|
||||
if not img then return nil end
|
||||
return img, #bands, bands
|
||||
end
|
||||
|
||||
-- How far the twilight glow reaches around the disc, in canvas pixels, for
|
||||
-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`.
|
||||
Sky.GLOW_REACH = 0.55
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
shader = false
|
||||
if love.graphics and love.graphics.newShader then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
if ok and sh then
|
||||
shader = sh
|
||||
elseif V and V.mod and V.mod.log then
|
||||
-- once, and only where it can be read: the fallback below is a sky
|
||||
-- without its dither, which is easy to look at and impossible to
|
||||
-- diagnose without this line
|
||||
V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, "
|
||||
.. "with no dither between them", tostring(sh))
|
||||
end
|
||||
end
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
Sky._getShader = getShader -- named for the suite
|
||||
|
||||
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
|
||||
-- quantised edges. For a driver that could not compile the shader -- which is
|
||||
-- also every headless run.
|
||||
local function paintFlat(w, h, bands, edge, alpha, cell)
|
||||
local g = love.graphics
|
||||
local n = #bands
|
||||
local prev = 0
|
||||
for i = 1, n do
|
||||
local cut = (i == n) and math.min(h, math.ceil(edge))
|
||||
or math.floor(i / n * edge / cell + 0.5) * cell
|
||||
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
|
||||
if cut > prev then
|
||||
local c = bands[i]
|
||||
g.setColor(c[1], c[2], c[3], alpha)
|
||||
g.rectangle("fill", 0, prev, w, cut - prev)
|
||||
end
|
||||
prev = cut
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the discs
|
||||
--
|
||||
-- The sun and moon, as cell art: a circle of whole diorama cells with a
|
||||
-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
|
||||
-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
|
||||
-- through the same display-mode transform as every palette here, and
|
||||
-- SCISSORED to the sky's region: the horizon point is where a setting body
|
||||
-- disappears, so it can never hang under the map at a high pitch.
|
||||
--
|
||||
-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
|
||||
-- an angle, so zooming the ground in and out must not swell and shrink the
|
||||
-- sun with it. The radius is a fraction of the frame height, converted to
|
||||
-- whole cells so the disc still sits on the diorama's grid -- chunky cells
|
||||
-- up close, fine ones at survey zoom, the same size body either way.
|
||||
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
|
||||
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
|
||||
|
||||
-- crater centres as fractions of the radius, so they ride any disc size.
|
||||
-- Public because the water's reflection draws the same moon (see Water):
|
||||
-- one list, so the disc on the lake cannot drift from the one in the sky.
|
||||
Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
|
||||
{ -0.15, 0.7 }, { 0.05, 0.05 } }
|
||||
|
||||
-- a crater's radius, as a fraction of the disc's -- the r/5 paintDisc uses
|
||||
Sky.CRATER_FRAC = 0.2
|
||||
|
||||
local MOON_CRATERS = Sky.MOON_CRATERS
|
||||
|
||||
-- The disc's four shades as the display mode has them, lightest first.
|
||||
-- Shared with the reflection pass, so the sun on the water is the same sun
|
||||
-- that is in the sky, in the same mode's palette.
|
||||
function Sky.discShades(moon)
|
||||
local src = moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
|
||||
return PaletteFX.effectiveColors(src) or src
|
||||
end
|
||||
|
||||
-- Whether this body is the LOOMING low sun -- the sunset exaggeration.
|
||||
local function looming(body)
|
||||
return (body.glowAmt or 0) > 0.25 and not body.moon
|
||||
end
|
||||
|
||||
-- The disc's radius for a `h`-tall frame on a `cell`-pixel grid: in CANVAS
|
||||
-- PIXELS, and in whole cells. Sized by the FRAME rather than by the world
|
||||
-- (see DISC_FRAC), so a zoom does not swell the sun.
|
||||
--
|
||||
-- Read by paintDisc below and by the reflection, which needs the same
|
||||
-- number in radians -- a disc drawn one size and mirrored another would
|
||||
-- read as two different suns.
|
||||
function Sky.discRadius(h, cell, body)
|
||||
cell = math.max(1, cell or 1)
|
||||
local r = math.max(Sky.DISC_MIN,
|
||||
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
|
||||
if body and looming(body) then r = r + math.max(1, math.floor(r * 0.4)) end
|
||||
return r * cell, r
|
||||
end
|
||||
|
||||
local function paintDisc(body, edge, cell, w, h)
|
||||
local g = love.graphics
|
||||
if not (body and body.y and g.setScissor) then return end
|
||||
local shades = Sky.discShades(body.moon)
|
||||
local twilight = looming(body)
|
||||
local _, r = Sky.discRadius(h, cell, body)
|
||||
-- snap the centre to the cell grid, like everything else in this sky
|
||||
local bx = math.floor(body.x / cell) * cell + cell / 2
|
||||
local by = math.floor(body.y / cell) * cell + cell / 2
|
||||
if by - r * cell > edge then return end -- wholly below the horizon point
|
||||
local core = shades[1]
|
||||
local main = shades[twilight and 3 or 2]
|
||||
local sx, sy, sw, sh = g.getScissor()
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
local craterR = math.max(1, math.floor(r / 5))
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
if d <= r + 0.1 then
|
||||
local c = d <= r * 0.5 and core or main
|
||||
-- dithered rim: the outer ring keeps only one parity of its cells
|
||||
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
|
||||
if body.moon then
|
||||
for _, cr in ipairs(MOON_CRATERS) do
|
||||
local cdx = dx - math.floor(cr[1] * r + 0.5)
|
||||
local cdy = dy - math.floor(cr[2] * r + 0.5)
|
||||
if cdx * cdx + cdy * cdy <= craterR * craterR then
|
||||
c = shades[3]
|
||||
end
|
||||
end
|
||||
end
|
||||
if keep then
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", bx + dx * cell - cell / 2,
|
||||
by + dy * cell - cell / 2, cell, cell)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Paint the sky into the bound canvas, filling it from the top edge down to
|
||||
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
|
||||
--
|
||||
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
|
||||
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
|
||||
--
|
||||
-- `body` is the sun or moon to hang, already projected to canvas pixels by
|
||||
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
|
||||
-- along; nil hangs nothing and warms nothing.
|
||||
--
|
||||
-- Returns false when there is nothing to paint, in which case the caller's flat
|
||||
-- fill is the whole sky. That fill is the palest band, so a frame that declines
|
||||
-- this looks like a hazy day rather than like a bug.
|
||||
function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
local bands = sky and sky.bands
|
||||
if not (bands and bands[1]) then return false end
|
||||
if not (w and h and w > 0 and h > 0) then return false end
|
||||
local g = love.graphics
|
||||
if not (g and g.rectangle) then return false end
|
||||
local edge = Sky.region(h, horizonY)
|
||||
if not edge then return false end
|
||||
local alpha = sky[4] or 1
|
||||
cell = math.max(1, math.floor((cell or 1) + 0.5))
|
||||
|
||||
-- State to put aside. The scene's shader is one, and the blend mode another --
|
||||
-- a pass that left "replace" behind would make the fade-in strength meaningless
|
||||
-- -- but the DEPTH MODE is the one that would break the frame: a rectangle
|
||||
-- drawn under the pass's own ("lequal", true) stamps itself across the depth
|
||||
-- buffer at the near plane and hides the entire world behind the sky.
|
||||
local prevShader = g.getShader and g.getShader() or nil
|
||||
local cmp, write
|
||||
if g.getDepthMode then cmp, write = g.getDepthMode() end
|
||||
if g.setDepthMode then g.setDepthMode("always", false) end
|
||||
local blend, blendAlpha
|
||||
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
|
||||
if g.setBlendMode then g.setBlendMode("alpha") end
|
||||
|
||||
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
|
||||
local sh = getShader()
|
||||
local ramp = sh and rampFor(bands)
|
||||
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
|
||||
if sh then
|
||||
local sent = pcall(function()
|
||||
-- the bands arrive as a texture, one texel each, and `count` is that
|
||||
-- texture's width -- see rampFor for why they are not a uniform array
|
||||
sh:send("ramp", ramp)
|
||||
sh:send("count", #bands)
|
||||
sh:send("edge", edge)
|
||||
sh:send("cell", cell)
|
||||
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
|
||||
sh:send("alpha", alpha)
|
||||
sh:send("glowAmt", glowAmt)
|
||||
if glowAmt > 0 then
|
||||
local gc = body.glowColor or { 248, 224, 168 }
|
||||
sh:send("glowPos", { body.x, body.y })
|
||||
sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
|
||||
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
|
||||
end
|
||||
end)
|
||||
if sent then
|
||||
g.setShader(sh)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
|
||||
g.setShader()
|
||||
else
|
||||
sh = nil
|
||||
end
|
||||
end
|
||||
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
|
||||
-- the disc goes over the glow, under nothing: plain rectangles, so it is
|
||||
-- there whether or not the shader built
|
||||
paintDisc(body, math.min(h, edge), cell, w, h)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
|
||||
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
|
||||
if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end
|
||||
if prevShader and g.setShader then g.setShader(prevShader) end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
|
||||
-- context builds a new one instead of drawing with a handle from the old. The
|
||||
-- ramp is a GPU object on the same context and goes with it.
|
||||
function Sky.invalidate()
|
||||
shader = nil
|
||||
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||
cache.ramp, cache.rampFor = nil, nil
|
||||
end
|
||||
|
||||
return Sky
|
||||
+1309
-138
File diff suppressed because it is too large
Load Diff
@@ -270,7 +270,12 @@ local function readback(image)
|
||||
local prev = love.graphics.getCanvas()
|
||||
local ok, data = pcall(function()
|
||||
local w, h = image:getDimensions()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
|
||||
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
|
||||
-- so the atlas would be drawn into a texture 2.75x its size and read
|
||||
-- back magnified -- and every tile coordinate below, which counts in
|
||||
-- eights from the top-left, would land somewhere between two tiles.
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
-- straight copy: no blending against the cleared target, no tint from
|
||||
|
||||
+351
-17
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
|
||||
sign = 12,
|
||||
wall = 16,
|
||||
tree = 16,
|
||||
-- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
|
||||
-- badge-check gates down Route 23 are drawn 32px, the same height as a
|
||||
-- statue on its plinth, and read as a step in the terrain rather than a
|
||||
-- room's wall. Same fold as `wall`, twice the height -- and its own
|
||||
-- class because `wall` is 16px for every interior in the game.
|
||||
cliff = 32,
|
||||
roof = 28,
|
||||
cylinder = 16,
|
||||
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
|
||||
@@ -65,6 +71,21 @@ local FALLBACK_HEIGHTS = {
|
||||
-- body builds from the bark rows and the drawn ellipse projects onto
|
||||
-- the hull's round top
|
||||
stump = 16,
|
||||
-- the same hull cut at both ends, hollowed and tapered: an OPEN bin
|
||||
-- standing on a floor (the Vermilion Gym trash cans). The drawn mouth
|
||||
-- ellipse projects onto the round top and down the well, the drawn base
|
||||
-- ellipse is ground contact rather than body, and the plan narrows toward
|
||||
-- the floor. Height is AUTHORED (the profile's can_height, which this
|
||||
-- pin must be kept equal to so anything riding a can lands on its rim) --
|
||||
-- the drawing's own straight run is only a couple of rows, because a GB
|
||||
-- cell spends most of itself on the opening
|
||||
can = 9,
|
||||
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
|
||||
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
|
||||
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
|
||||
-- the object's height, not its depth. BOTH cells take the class; the
|
||||
-- group build anchors on the north one (Structures.buildCylinders)
|
||||
planter = 32,
|
||||
billboard = 16,
|
||||
signpost = 16,
|
||||
post = 16,
|
||||
@@ -77,10 +98,18 @@ local FALLBACK_HEIGHTS = {
|
||||
bed = 7,
|
||||
stool = 8,
|
||||
counter = 8,
|
||||
-- the raised back band of low seating: the Center couch's west strip
|
||||
-- is drawn from above like the rest of the couch, but depicts the
|
||||
-- back and arm rising over the 8px seat
|
||||
backrest = 12,
|
||||
table = 12,
|
||||
desk = 24,
|
||||
prop = 16,
|
||||
cutout = 16,
|
||||
-- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like
|
||||
-- every other cutout pool -- what differs is the thickness (see
|
||||
-- Structures' PINNED_DEPTH)
|
||||
bike = 16,
|
||||
console = 16,
|
||||
relief = 3,
|
||||
bookcase = 32,
|
||||
@@ -110,12 +139,15 @@ local ART = {
|
||||
ledge = "top",
|
||||
roof = "top",
|
||||
wall = "upright",
|
||||
cliff = "upright",
|
||||
tree = "upright",
|
||||
fence = "upright",
|
||||
sign = "upright",
|
||||
cylinder = "cylinder",
|
||||
canopy = "canopy",
|
||||
stump = "cylinder",
|
||||
can = "cylinder",
|
||||
planter = "planter",
|
||||
billboard = "billboard",
|
||||
-- signposts share the billboard treatment but as their own pool at a
|
||||
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
|
||||
@@ -138,6 +170,9 @@ local ART = {
|
||||
-- profile archetype Structures builds real steps for -- rising flights
|
||||
-- for stairs leading up, sunken stairwells for stairs leading down
|
||||
bed = "top",
|
||||
-- a backrest's art is the couch seen from above, so like the bed it
|
||||
-- rides the top face of its taller box
|
||||
backrest = "top",
|
||||
stool = "billboard",
|
||||
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
||||
-- so exactly the drawing's bottom row stands up as the front and
|
||||
@@ -151,6 +186,13 @@ local ART = {
|
||||
desk = "upright",
|
||||
prop = "billboard",
|
||||
cutout = "billboard",
|
||||
-- a bicycle is a LINE drawing seen side-on, and its negative space --
|
||||
-- the air inside the frame, between the wheel and the fork -- is what
|
||||
-- makes it read as a bicycle at all. Its own pool at two voxels: any
|
||||
-- thicker and the side faces of neighbouring strokes close those gaps
|
||||
-- from every angle but dead-on, and six of them in a showroom come out
|
||||
-- as one dark lump (which is what the 5px `prop` pool gave)
|
||||
bike = "billboard",
|
||||
-- a machine standing on furniture: the billboard treatment with
|
||||
-- body, plus the one-object contract `cutout` has -- the drawing is
|
||||
-- ringed by the furniture it sits on, and those edges must not be
|
||||
@@ -168,6 +210,9 @@ local ART = {
|
||||
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
local cache = {} -- tileset id -> resolved shape list
|
||||
local figCache = {} -- tileset id -> parsed figure masks, or false
|
||||
local mntCache = {} -- tileset id -> parsed mounted masks, or false
|
||||
local bgCache = {} -- tileset id -> prop background shades, or false
|
||||
|
||||
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
|
||||
-- through the mod's own file loader rather than package.path: a mod's
|
||||
@@ -224,29 +269,42 @@ end
|
||||
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
|
||||
-- the map and coordinates are in hand. First match wins; no match keeps
|
||||
-- the tile's ordinary pin.
|
||||
-- `when_below` is the mirror, and it exists because ABOVE is not always the
|
||||
-- side that tells the two uses apart. The Plateau's $0D is the case: it is
|
||||
-- the gate wall's top band AND the base course under a column of rock face,
|
||||
-- and scanned over both maps the tile above is $03 for 64 of the first and
|
||||
-- 140 of the second -- no rule on `above` can split them. What is BELOW
|
||||
-- does, exactly: the wall's own face $0F sits under the top band and under
|
||||
-- nothing else (336 vs 352, clean).
|
||||
local function authoredConditions(tilesetId, heights)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local spec = entry and entry.when_above
|
||||
if type(spec) ~= "table" then return nil end
|
||||
if type(entry) ~= "table" then return nil end
|
||||
local out, any = {}, false
|
||||
for tile, rules in pairs(spec) do
|
||||
if type(tile) == "number" and type(rules) == "table" then
|
||||
local list = {}
|
||||
for _, rule in ipairs(rules) do
|
||||
if type(rule) == "table" and heights[rule.class]
|
||||
and type(rule.above) == "table" then
|
||||
local set = {}
|
||||
for _, t in ipairs(rule.above) do set[t] = true end
|
||||
list[#list + 1] = { above = set, class = rule.class }
|
||||
|
||||
local function collect(spec, side)
|
||||
if type(spec) ~= "table" then return end
|
||||
for tile, rules in pairs(spec) do
|
||||
if type(tile) == "number" and type(rules) == "table" then
|
||||
local list = out[tile] or {}
|
||||
for _, rule in ipairs(rules) do
|
||||
if type(rule) == "table" and heights[rule.class]
|
||||
and type(rule[side]) == "table" then
|
||||
local set = {}
|
||||
for _, t in ipairs(rule[side]) do set[t] = true end
|
||||
list[#list + 1] = { side = side, set = set, class = rule.class }
|
||||
end
|
||||
end
|
||||
if #list > 0 then
|
||||
out[tile] = list
|
||||
any = true
|
||||
end
|
||||
end
|
||||
if #list > 0 then
|
||||
out[tile] = list
|
||||
any = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
collect(entry.when_above, "above")
|
||||
collect(entry.when_below, "below")
|
||||
return any and out or nil
|
||||
end
|
||||
|
||||
@@ -273,6 +331,24 @@ function TileShape.forMap(map)
|
||||
if cache[id] then return cache[id] end
|
||||
|
||||
local heights = TileShape.heights()
|
||||
-- Per-tileset height overrides (a tileset entry's `heights`): the class
|
||||
-- vocabulary is global but the drawings are not -- the DOJO lab tables
|
||||
-- are drawn 6px tall where the default `table` is 12 -- and the height
|
||||
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
|
||||
-- actually stands, or the starter balls float over their own table.
|
||||
-- Same gate as the global list: known classes, numbers only.
|
||||
do
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[id]
|
||||
local over = entry and entry.heights
|
||||
if type(over) == "table" then
|
||||
for class, h in pairs(over) do
|
||||
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
|
||||
heights[class] = h
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local authored = authoredGroups(id, heights)
|
||||
local count = math.floor((tileset.imageWidth or 128) / 8)
|
||||
* math.floor((tileset.imageHeight or 48) / 8)
|
||||
@@ -348,9 +424,12 @@ function TileShape.at(map, shapes, tile, tx, ty)
|
||||
-- tile and the cell rules below (see authoredConditions)
|
||||
local rules = shapes.cond and shapes.cond[tile]
|
||||
if rules then
|
||||
local above = map:tileAt(tx, ty - 1)
|
||||
for _, rule in ipairs(rules) do
|
||||
if above and rule.above[above] then
|
||||
-- NOTE map:tileAt border-EXTENDS: one row off an edge answers the
|
||||
-- map's borderBlock, never nil. A rule listing whatever that block
|
||||
-- draws will fire along that whole edge (it did, on the Marts).
|
||||
local n = map:tileAt(tx, rule.side == "above" and ty - 1 or ty + 1)
|
||||
if n and rule.set[n] then
|
||||
-- shapes.condShape, NOT shapes.classes: the canonical class
|
||||
-- shapes are SHARED, and `wall` in particular is the very object
|
||||
-- rule 4 hands every unauthored solid tile. Marking that one
|
||||
@@ -369,11 +448,266 @@ function TileShape.at(map, shapes, tile, tx, ty)
|
||||
return s
|
||||
end
|
||||
|
||||
-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture,
|
||||
-- cut out by an explicit pixel mask and stood up on top of it.
|
||||
--
|
||||
-- Every other route in this file resolves a whole 8x8 TILE, which is
|
||||
-- exactly why none of them can reach a figure that shares its tiles with
|
||||
-- the thing it sits on -- and the detector's segmentation cannot either
|
||||
-- when the drawing has no background margin to flood from and wears the
|
||||
-- same shades as its furniture. So the profile authors the silhouette
|
||||
-- pixel by pixel (see data/voxel_heights.lua):
|
||||
--
|
||||
-- figures = { { w = <tiles across>,
|
||||
-- depth = <voxels of body; ABSENT for a person>,
|
||||
-- thin = { rows = <top rows>, depth = <voxels> },
|
||||
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
-- under = { ...w*h ids: what each tile wears once the
|
||||
-- figure is lifted off it... },
|
||||
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
|
||||
-- figure... } } }
|
||||
--
|
||||
-- No class -- what the entry carries instead is a `depth`, or does not:
|
||||
--
|
||||
-- WITHOUT one it is a flat sprite card, drawn the way SpriteBillboards
|
||||
-- draws a character. That is the right reading for a PERSON: a Gen 1
|
||||
-- figure is a face-on 2D icon, and extruding one reconstructs a body
|
||||
-- nobody drew (see Structures.buildFigures).
|
||||
-- WITH one it is an OBJECT and gets the standee treatment every other
|
||||
-- solid here gets -- a per-pixel slab in world space, standing on the
|
||||
-- same furniture the card would have stood on. The Marts' cash
|
||||
-- register is the case: a machine on a counter is a box, not an icon.
|
||||
--
|
||||
-- Two fields say which parts of such a drawing are NOT the extrusion,
|
||||
-- because a solid drawn in one 16x16 GB cell still packs more than one
|
||||
-- facing:
|
||||
--
|
||||
-- `thin` caps the thickness over the mask's top rows, for the part of
|
||||
-- the drawing that is not the machine (the register's receipt curl).
|
||||
-- `flat` names a rect of the mask that is a TOP-VIEW surface rather
|
||||
-- than a face -- the register's keypad, whose keys lie ON its deck.
|
||||
-- The rect lays horizontal one voxel proud of whatever the extrusion
|
||||
-- leaves below it, at the elevation its BOTTOM row would have had,
|
||||
-- with drawn row = depth row 1:1 (the mapping the lab tabletop is
|
||||
-- drawn with). So a drawing whose front elevation is an L reads as
|
||||
-- one: body up the side and along the base, keys lying in the notch.
|
||||
--
|
||||
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
|
||||
-- Structures can read it as a bitmap without re-parsing per position.
|
||||
-- A malformed entry is dropped rather than half-applied -- a typo in a
|
||||
-- mask should leave the couch alone, not carve a hole in it.
|
||||
--
|
||||
-- `mounted` (below) carries the same four fields, so the parse is shared,
|
||||
-- and so are the optional ones that give an authored mask a BODY: `depth`,
|
||||
-- `thin` and `flat` above. `depth` is left nil when unstated, because
|
||||
-- absence is meaningful on a figure: no depth means the flat sprite card a
|
||||
-- person is drawn as.
|
||||
local function authoredMasks(list)
|
||||
local out = {}
|
||||
if type(list) ~= "table" then return out end
|
||||
for _, f in ipairs(list) do
|
||||
local ok = type(f) == "table" and type(f.w) == "number"
|
||||
and type(f.tiles) == "table" and type(f.under) == "table"
|
||||
and type(f.pixels) == "table"
|
||||
local w = ok and math.floor(f.w) or 0
|
||||
local h = (w >= 1) and (#f.tiles / w) or 0
|
||||
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
|
||||
and #f.under == #f.tiles and #f.pixels == h * 8
|
||||
if ok then
|
||||
for i = 1, h * 8 do
|
||||
local row = f.pixels[i]
|
||||
if type(row) ~= "string" or #row ~= w * 8 then
|
||||
ok = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
local mask, n = {}, 0
|
||||
for ly = 0, h * 8 - 1 do
|
||||
local row = f.pixels[ly + 1]
|
||||
for lx = 0, w * 8 - 1 do
|
||||
if row:sub(lx + 1, lx + 1) ~= "." then
|
||||
mask[ly * (w * 8) + lx] = true
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
local depth = tonumber(f.depth)
|
||||
local thin = nil
|
||||
if type(f.thin) == "table" and tonumber(f.thin.rows)
|
||||
and tonumber(f.thin.depth) then
|
||||
thin = { rows = math.floor(tonumber(f.thin.rows)),
|
||||
depth = math.floor(tonumber(f.thin.depth)) }
|
||||
end
|
||||
local flat = nil
|
||||
if type(f.flat) == "table" and type(f.flat.x) == "table"
|
||||
and type(f.flat.rows) == "table" then
|
||||
flat = { x0 = math.floor(f.flat.x[1]), x1 = math.floor(f.flat.x[2]),
|
||||
r0 = math.floor(f.flat.rows[1]),
|
||||
r1 = math.floor(f.flat.rows[2]) }
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under,
|
||||
depth = depth and math.floor(depth) or nil,
|
||||
thin = thin, flat = flat }
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function TileShape.figures(tilesetId)
|
||||
local hit = figCache[tilesetId]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local out = authoredMasks(entry and entry.figures)
|
||||
|
||||
figCache[tilesetId] = (#out > 0) and out or false
|
||||
return figCache[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- Hand-authored MOUNTED objects for one tileset: a thing drawn INTO the
|
||||
-- wall band it hangs on, cut out by an explicit pixel mask and stood
|
||||
-- proud of the wall's face.
|
||||
--
|
||||
-- Same authoring problem as `figures` and the same answer -- a class pin
|
||||
-- resolves a whole 8x8 tile, and the detector cannot segment a drawing
|
||||
-- that has no background margin to flood from. The Bike Shop's two wall
|
||||
-- bicycles are the case: the shop's striped wall panel runs BEHIND them,
|
||||
-- and its #555 stripes are a flood boundary, so a silhouette flood comes
|
||||
-- back with the stripes attached to the bike.
|
||||
--
|
||||
-- Two things differ from a figure, and both follow from the object being
|
||||
-- an object rather than a character:
|
||||
--
|
||||
-- it keeps its DRAWN ELEVATION. A figure stands on its own feet; a
|
||||
-- mounted thing sits where the wall band draws it, so a bicycle hung
|
||||
-- clear of the floor stays hung.
|
||||
-- it has THICKNESS (`depth`, default 2), and it is built in world
|
||||
-- space as a per-pixel slab jutting south of the band -- not as a
|
||||
-- camera-facing sprite card. A bicycle drawn side-on is a plane
|
||||
-- parallel to the wall, not a face-on icon.
|
||||
--
|
||||
-- mounted = { { w = <tiles across>,
|
||||
-- depth = <voxels it juts into the room>,
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
-- under = { ...w*h ids: what each tile wears once the
|
||||
-- object is lifted off it (the plain panel)... },
|
||||
-- pixels = { ...h*8 strings of w*8 chars, "." = wall... } } }
|
||||
function TileShape.mounted(tilesetId)
|
||||
local hit = mntCache[tilesetId]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local out = authoredMasks(entry and entry.mounted)
|
||||
|
||||
mntCache[tilesetId] = (#out > 0) and out or false
|
||||
return mntCache[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
|
||||
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
|
||||
--
|
||||
-- Structures normally votes on this by reading the shades that touch the
|
||||
-- drawing's own bounding box, which is right whenever the drawing has a
|
||||
-- margin of floor around it and wrong when it does not: a prop whose body
|
||||
-- reaches its own edge votes itself out. Naming the shades is the override,
|
||||
-- and it is keyed by TILE because the answer is per drawing rather than per
|
||||
-- tileset -- two props in one atlas can want opposite calls on the same
|
||||
-- shade (see the POKECENTER entry).
|
||||
--
|
||||
-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } }
|
||||
--
|
||||
-- Only the four GB shade names exist; anything else is dropped, so a typo
|
||||
-- degrades to the ordinary vote rather than emptying the background.
|
||||
local SHADES = { black = true, dark = true, light = true, white = true }
|
||||
|
||||
function TileShape.propBg(tilesetId)
|
||||
local hit = bgCache[tilesetId]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local list = entry and entry.prop_bg
|
||||
local out, any = {}, false
|
||||
if type(list) == "table" then
|
||||
for _, rule in ipairs(list) do
|
||||
if type(rule) == "table" and type(rule.tiles) == "table"
|
||||
and type(rule.shades) == "table" then
|
||||
local set, n = {}, 0
|
||||
for _, name in ipairs(rule.shades) do
|
||||
if SHADES[name] then
|
||||
set[name] = true
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
for _, t in ipairs(rule.tiles) do
|
||||
if type(t) == "number" then
|
||||
out[t] = set
|
||||
any = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
bgCache[tilesetId] = any and out or false
|
||||
return bgCache[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- What a bookcase rank does with the rows it VACATES -- the ones behind the
|
||||
-- one-cell-deep box it collapses onto (a tileset entry's
|
||||
-- bookcase_backfill). Returns the mode name, or nil for the default.
|
||||
--
|
||||
-- "above" hand them the cell immediately above the run: its shape and
|
||||
-- its art. A wall set INTO a terrace wants this -- the ground
|
||||
-- behind it is more terrace, not a trench.
|
||||
-- nil skip them and paint the map's commonest ground underneath,
|
||||
-- which is right for a free-standing shelf against a wall.
|
||||
--
|
||||
-- Per tileset because it is a statement about what the drawing depicts, and
|
||||
-- the answer differs: the Mart's racks and Red's shelves stand in a room,
|
||||
-- the Plateau's gate walls are cut into a hillside.
|
||||
function TileShape.bookcaseBackfill(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local mode = entry and entry.bookcase_backfill
|
||||
return mode == "above" and mode or nil
|
||||
end
|
||||
|
||||
--- Does this tileset's `bookcase` run carry the measured pane RELIEF on
|
||||
--- its front (a tileset entry's bookcase_relief)? Default yes: the class
|
||||
--- almost always collapses a shelf, a rack or a display case, and every
|
||||
--- one of those seals its contents behind a frame that should stand proud
|
||||
--- of them.
|
||||
---
|
||||
--- A tileset says `bookcase_relief = false` when it borrows the collapse
|
||||
--- for something that is NOT a shelf -- the League's gate walls and
|
||||
--- pilasters, Bill's transporter drums -- where the drawing's light
|
||||
--- regions are the masonry and the barrel, not panes, and sinking them
|
||||
--- carves the surface instead of describing it.
|
||||
function TileShape.bookcaseRelief(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
return not (entry and entry.bookcase_relief == false)
|
||||
end
|
||||
|
||||
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
|
||||
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||
function TileShape.invalidate()
|
||||
spec = nil
|
||||
cache = {}
|
||||
figCache = {}
|
||||
mntCache = {}
|
||||
bgCache = {}
|
||||
end
|
||||
|
||||
return TileShape
|
||||
|
||||
+452
-20
@@ -30,6 +30,9 @@ local Voxel = V.require("VoxelState")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local Sky = V.require("Sky")
|
||||
local DayNight = V.require("DayNight")
|
||||
local GlassMask = V.require("GlassMask")
|
||||
|
||||
local Voxel3D = {}
|
||||
|
||||
@@ -200,6 +203,13 @@ local SHADER = [[
|
||||
|
||||
uniform vec3 ghostColor; // the flat silhouette colour
|
||||
uniform float ghost; // 0 = shade normally, 1 = flatten to it
|
||||
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
|
||||
uniform Image glassMask; // opaque where the atlas texel is window glass
|
||||
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
|
||||
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
|
||||
uniform float glassPhase; // the glint's phase: advances with TRAVEL
|
||||
uniform float glassGlint; // and its strength: 0 while standing still
|
||||
uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass)
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
@@ -207,12 +217,44 @@ local SHADER = [[
|
||||
// blending keeps those texels out of the depth buffer, so a model never
|
||||
// carves a transparent hole out of whatever stands behind it
|
||||
if (p.a < 0.5) discard;
|
||||
vec3 rgb = p.rgb * vShade * sunlight(vSun);
|
||||
// the hour's tint multiplies like the sun terms do: it is LIGHT, the
|
||||
// same warm or moonlit cast on every surface, not a palette swap
|
||||
vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
|
||||
#ifdef VOXEL_GRID
|
||||
// darken what is there rather than painting a colour, so a seam across
|
||||
// dark grass and one across a white roof each stay in their own palette
|
||||
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
|
||||
#endif
|
||||
// WINDOW GLASS, marked per atlas texel by the mask (see GlassMask).
|
||||
// By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES
|
||||
// -- the phase is fed by the camera's own travel and the strength dies
|
||||
// within a beat of standing still, because a reflection is something
|
||||
// the viewpoint does: still camera, still glass. It lifts the texel
|
||||
// toward sky-white and leaves the art visible through it. After dark
|
||||
// the pane is LIT: the texel's own shine pattern carried into a warm
|
||||
// lamp colour, replacing the shaded answer above -- so a lit window
|
||||
// ignores the sun, every shadow and the hour's tint, exactly as a
|
||||
// window with a lamp behind it does.
|
||||
// glassOn gates the whole thing per DRAW: the mask is shaped like the
|
||||
// tileset atlas, and only meshes textured FROM that atlas may consult
|
||||
// it -- a character samples its own sprite sheet, whose coordinates
|
||||
// land on the mask's pane rectangles by accident and would stripe the
|
||||
// cast with lamplight at night.
|
||||
float glass = Texel(glassMask, tc).a * glassOn;
|
||||
if (glass > 0.0) {
|
||||
// the sweep lives in the PANE's own space (atlas texels), not the
|
||||
// screen's: a pattern anchored to the screen has the world sliding
|
||||
// through it at zoom speed whenever the camera pans, which strobed --
|
||||
// worst where the pan and the phase ran opposite ways. Anchored to
|
||||
// the glass, panning moves nothing; only the phase does, a fraction
|
||||
// of a texel per step, the same in every walking direction.
|
||||
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
|
||||
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
|
||||
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
|
||||
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
|
||||
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
|
||||
rgb = mix(pane, lamp, glassNight * glass);
|
||||
}
|
||||
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
|
||||
// through `color` could only multiply the sprite's own pixels, which
|
||||
// darkens each one by its own amount and keeps the character's internal
|
||||
@@ -241,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 --
|
||||
@@ -321,7 +421,8 @@ end
|
||||
-- ---------------------------------------------------------------- camera --
|
||||
|
||||
-- An explicit camera, replacing the orbit below for as long as it is set:
|
||||
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
|
||||
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil,
|
||||
-- up = {x,y,z} or nil }.
|
||||
--
|
||||
-- The orbit is the free-roam camera and it is described entirely by ONE
|
||||
-- number, the pitch, because that is all a camera following the player over
|
||||
@@ -337,6 +438,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)
|
||||
@@ -344,18 +480,29 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
if cam then
|
||||
local eye, focus = cam.eye, cam.focus
|
||||
Voxel3D.eye = eye
|
||||
-- kept beside the eye for horizonY: where the sky's pale end goes is a
|
||||
-- question about which way this camera looks, and only these two answer it
|
||||
Voxel3D.focus = focus
|
||||
setLook(eye, focus)
|
||||
local dx = eye[1] - focus[1]
|
||||
local dy = eye[2] - focus[2]
|
||||
local dz = eye[3] - focus[3]
|
||||
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
|
||||
-- kept for the passes that measure an ANGLE against this camera rather
|
||||
-- than a position: the water's reflected sun is sized in radians, and
|
||||
-- radians per canvas pixel is exactly this over the frame height
|
||||
Voxel3D.fovY = cam.fov
|
||||
local proj = Mat4.perspective(cam.fov, vw / vh,
|
||||
math.max(1, dist * 0.05), dist * 4 + 4096)
|
||||
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
||||
-- bypass LOVE's transform_projection and canvas coordinates run Y down
|
||||
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
||||
-- world up, so the horizon stays level -- a placed camera that rolled
|
||||
-- with its own pitch would tip the whole arena
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
|
||||
-- world up by default, so the horizon stays level -- a placed camera
|
||||
-- that rolled with its own pitch would tip the whole arena. A caller
|
||||
-- may hand its own up: the first-person BLEND does, because its far
|
||||
-- end is the orbit, whose up leans with the pitch -- world up at the
|
||||
-- orbit's steep end degenerates against a straight-down view.
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 }))
|
||||
end
|
||||
|
||||
local a = Voxel.angle
|
||||
@@ -364,11 +511,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.
|
||||
@@ -386,6 +536,92 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
|
||||
end
|
||||
|
||||
-- ------- the horizon
|
||||
--
|
||||
-- Where the ground plane's vanishing line lands, in canvas pixels down from the
|
||||
-- top edge, or nil when this camera has no horizon to find.
|
||||
--
|
||||
-- Not a fraction picked by eye. A direction ALONG the ground is a point at
|
||||
-- infinity, and putting one through the same matrix the geometry is drawn with
|
||||
-- gives the line every ground plane in the scene converges on -- so the sky's
|
||||
-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides
|
||||
-- the camera tween instead of having to be retuned against it.
|
||||
--
|
||||
-- The world CURVE is not in it, and cannot be: it bends distant ground down in
|
||||
-- the vertex shader, so the ground's apparent edge sits BELOW this line by
|
||||
-- however much the bend took. What shows in between is the haze the sky's fill
|
||||
-- already is, which is what a curved-away horizon should look like.
|
||||
--
|
||||
-- nil in two cases, both meaning "no horizon in this frame": a camera looking
|
||||
-- straight down, whose forward direction has no horizontal part to send to
|
||||
-- infinity, and one whose vanishing line is behind it.
|
||||
function Voxel3D.horizonY(h)
|
||||
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
|
||||
if not (m and eye and focus and h and h > 0) then return nil end
|
||||
local dx = focus[1] - eye[1]
|
||||
local dz = focus[3] - eye[3]
|
||||
local len = math.sqrt(dx * dx + dz * dz)
|
||||
if len < 1e-6 then return nil end
|
||||
dx, dz = dx / len, dz / len
|
||||
-- a DIRECTION, so its w is zero and the matrix's translation column drops
|
||||
-- out; the clip-space Y flip is already baked into m, so this comes out in
|
||||
-- canvas coordinates rather than needing one
|
||||
local y = m[5] * dx + m[7] * dz
|
||||
local w = m[13] * dx + m[15] * dz
|
||||
if w <= 1e-6 then return nil end
|
||||
return (y / w * 0.5 + 0.5) * h
|
||||
end
|
||||
|
||||
-- ------- the hour's light
|
||||
--
|
||||
-- What the scene shader multiplies every surface by (see dayTint in the
|
||||
-- shader). Set per pass by whoever knows what map is being drawn --
|
||||
-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
|
||||
-- outdoors" is the map's question, not this pass's. Neutral until somebody
|
||||
-- answers it, so a caller that never does draws exactly what it always drew.
|
||||
Voxel3D.tint = { 1, 1, 1 }
|
||||
|
||||
-- The window-glass pass, set the same way and for the same reason: the
|
||||
-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
|
||||
-- panes are belongs to the hour and to being outdoors at all
|
||||
-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect.
|
||||
Voxel3D.glassMask = nil
|
||||
Voxel3D.glassNight = 0
|
||||
|
||||
-- the glint, fed by the camera's TRAVEL rather than by a clock (see
|
||||
-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and
|
||||
-- the strength is 0 whenever the view has been still for a beat
|
||||
Voxel3D.glassPhase = 0
|
||||
Voxel3D.glassGlint = 0
|
||||
|
||||
-- The sun or moon disc's place on this camera's canvas, or nil when the
|
||||
-- body is set, on the southern half of the sky, or behind the camera.
|
||||
--
|
||||
-- The direction comes from DayNight (true bearing, squashed elevation) and
|
||||
-- goes through the SAME matrix the geometry is drawn with, as a point at
|
||||
-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
|
||||
-- azimuth is honest: it stands over the point on the horizon its shadows
|
||||
-- point away from, at every pitch, fov, window shape and zoom.
|
||||
--
|
||||
-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
|
||||
function Voxel3D.skyBody(w, h)
|
||||
local m = Voxel3D.vp
|
||||
local b = m and DayNight.body()
|
||||
if not b then return nil end
|
||||
local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
|
||||
local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
|
||||
local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
|
||||
if ww <= 1e-6 then return nil end
|
||||
local amt, color = DayNight.glow()
|
||||
return {
|
||||
x = (x / ww * 0.5 + 0.5) * w,
|
||||
y = (y / ww * 0.5 + 0.5) * h,
|
||||
moon = b.moon,
|
||||
glowAmt = amt,
|
||||
glowColor = color,
|
||||
}
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- scene --
|
||||
|
||||
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
|
||||
@@ -406,28 +642,58 @@ 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
|
||||
end
|
||||
-- Ahead of the clear, because the sky's bands are placed off the ground
|
||||
-- plane's vanishing line and that is a property of this matrix.
|
||||
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
-- This frame's pixels per WORLD pixel: the size a diorama pixel is on
|
||||
-- screen. The sky's dither grid is cut to it, and so is the water's --
|
||||
-- one number, so the two break up on the same checkerboard.
|
||||
Voxel3D.cell = w / math.max(1, vw or w)
|
||||
-- and where the sky's bottom edge lands, which is what the reflection
|
||||
-- reads its bands against (see Water). nil when nothing painted bands.
|
||||
Voxel3D.skyEdge = (sky and sky.bands)
|
||||
and Sky.region(h, Voxel3D.horizonY(h)) or nil
|
||||
if sky then
|
||||
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
|
||||
-- The sky goes down here, in the one window in this function where a
|
||||
-- rectangle is just a rectangle: the depth mode and the scene shader are
|
||||
-- both set below. Sky.paint puts them aside anyway -- beginScene is not the
|
||||
-- only thing that has ever left a shader bound.
|
||||
--
|
||||
-- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
|
||||
-- pixel is on screen: the sky's dither grid is cut to that, so its squares
|
||||
-- are the same size as the world's own and follow every resize and zoom.
|
||||
-- The banded sky also hangs the hour's sun or moon (skyBody projects it
|
||||
-- through this very camera); a flat sky has no bands and hangs nothing.
|
||||
Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell,
|
||||
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||
else
|
||||
love.graphics.clear(0, 0, 0, 0, true, true)
|
||||
end
|
||||
@@ -438,7 +704,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
love.graphics.setMeshCullMode("none")
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
|
||||
pcall(sh.send, sh, "eye", Voxel3D.eye)
|
||||
-- the sun's frame, filled by ShadowMap just before this pass opened.
|
||||
@@ -454,7 +719,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
||||
if grid then
|
||||
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
|
||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
|
||||
end
|
||||
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
||||
-- every frame rather than once, because a scene that opened mid-ghost --
|
||||
@@ -462,6 +727,22 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- start out flattening everything it drew.
|
||||
pcall(sh.send, sh, "ghost", 0)
|
||||
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
|
||||
-- the hour's light, as the caller last set it (see Voxel3D.tint)
|
||||
pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
|
||||
-- the window glass: the tileset's mask (or the blank -- the sampler is
|
||||
-- declared either way, and unbound is a driver-dependent crash), how lit
|
||||
-- the panes are, and the movement-fed glint as the caller last set it
|
||||
local mask = Voxel3D.glassMask or GlassMask.blank()
|
||||
if mask then
|
||||
pcall(sh.send, sh, "glassMask", mask)
|
||||
local ok, mw, mh = pcall(mask.getDimensions, mask)
|
||||
pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 })
|
||||
end
|
||||
pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0)
|
||||
pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0)
|
||||
pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0)
|
||||
-- on until a sprite pass says otherwise, reset per frame like `ghost`
|
||||
pcall(sh.send, sh, "glassOn", 1)
|
||||
-- the curved world bends about the camera's focus, so the horizon keeps
|
||||
-- a fixed distance ahead of the player rather than sitting on the map.
|
||||
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
|
||||
@@ -558,6 +839,146 @@ 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.
|
||||
--
|
||||
-- The wireframe reads a mesh's OWN model space and darkens its integer
|
||||
-- planes (see VoxelGrid), which is only a wireframe because every mesh in
|
||||
-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a
|
||||
-- character card in the sprite's own pixels. A mesh whose model space does
|
||||
-- not mean that gets no wireframe out of the same shader -- it gets
|
||||
-- whichever of its integer planes happen to fall inside it, which is a
|
||||
-- stray line rather than a seam.
|
||||
--
|
||||
-- So this is not a style switch. It is how a mesh that is not on the voxel
|
||||
-- grid says so, and the alternative -- rescaling such a mesh until its
|
||||
-- units happen to be voxels -- would change what it IS to satisfy a
|
||||
-- shading pass.
|
||||
--
|
||||
-- Sent rather than branched because the plain scene shader has no such
|
||||
-- uniform, and the send simply does not take there -- which is right: with
|
||||
-- no wireframe compiled in there is nothing to suppress.
|
||||
function Voxel3D.seams(on)
|
||||
if not (active and activeShader) then return end
|
||||
pcall(activeShader.send, activeShader, "gridDark",
|
||||
on and VoxelGrid.DARK or 0)
|
||||
end
|
||||
|
||||
-- Whether what is drawn next may consult the glass mask. false for the
|
||||
-- length of a sprite-sheet pass, true to put it back.
|
||||
--
|
||||
-- Same shape as seams(), for the same reason: the mask means "this ATLAS
|
||||
-- texel is window glass", so it is only an answer for meshes textured from
|
||||
-- the tileset atlas. A sprite sheet's coordinates land wherever they land
|
||||
-- on it, and at night that painted lamplight stripes down whoever was
|
||||
-- standing in the wrong part of their own sheet.
|
||||
function Voxel3D.glass(on)
|
||||
if not (active and activeShader) then return end
|
||||
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
|
||||
end
|
||||
|
||||
function Voxel3D.endGhost()
|
||||
if not active then return end
|
||||
pcall(love.graphics.setDepthMode, "lequal", true)
|
||||
@@ -731,16 +1152,27 @@ function Voxel3D.canvas()
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- The bound canvas's pixel size, for a pass that has to work in screen
|
||||
-- coordinates (the water's reflection marches in them).
|
||||
function Voxel3D.size()
|
||||
return canvasW, canvasH
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function Voxel3D.invalidate()
|
||||
for name, 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
|
||||
|
||||
return Voxel3D
|
||||
|
||||
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
|
||||
-- 1.0 here is the one-pixel wireframe.
|
||||
VoxelGrid.WIDTH = 1.0
|
||||
|
||||
-- The same width in the CANVAS pixels the shader measures in, which is what
|
||||
-- every sender of it actually wants.
|
||||
--
|
||||
-- The two are the same number until AA renders the pass larger than the
|
||||
-- window (see AntiAlias): there a canvas pixel is a fraction of a display
|
||||
-- one, and a width left at 1.0 would come out a half or a quarter of a line
|
||||
-- after the fold -- the wireframe fading as the smoothing goes up, which
|
||||
-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and
|
||||
-- simply gains the antialiasing everything else in the frame just gained.
|
||||
function VoxelGrid.width()
|
||||
return VoxelGrid.WIDTH * V.require("AntiAlias").factor()
|
||||
end
|
||||
|
||||
-- where it persists and the rows that cycle it (see ModSetting)
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
+511
-51
@@ -1,4 +1,4 @@
|
||||
-- Voxel world mode: assemble and draw one frame of the 3D scene.
|
||||
-- Voxel world mode: assemble and draw one frame of the 3D scene.
|
||||
--
|
||||
-- World space is world pixels and shares its origin with the 2D paths, so
|
||||
-- the terrain mesh needs no transform at all and a connected map just
|
||||
@@ -20,6 +20,11 @@ local SpriteBillboards = V.require("SpriteBillboards")
|
||||
local TileShape = V.require("TileShape")
|
||||
local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Sky = V.require("Sky")
|
||||
local Water = V.require("Water")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -49,12 +54,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
|
||||
|
||||
-- ------------------------------------------------------------------ sky --
|
||||
--
|
||||
-- At the top rung the camera is pitched far enough over that the horizon
|
||||
-- comes into frame and a good part of the picture is void -- so the void
|
||||
-- becomes the sky, and the diorama reads as standing under something
|
||||
-- rather than floating on a black plate. Below that rung the camera looks
|
||||
-- down steeply enough that the horizon is off-screen, and painting the
|
||||
-- void only tints the gaps between meshes, so it stays transparent.
|
||||
-- The void behind the diorama is SKY, at every rung -- so the world reads as
|
||||
-- standing under something rather than floating on a black plate.
|
||||
--
|
||||
-- What is up there differs by rung, and the sky follows it rather than being
|
||||
-- retuned for each. At 75 degrees the camera is pitched far enough over that
|
||||
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
|
||||
-- steeper rungs the horizon is above the top edge and the void that shows is
|
||||
-- where the ground runs OUT -- past the map edge, past the curve -- so the
|
||||
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
|
||||
-- haze below them fills the rest.
|
||||
--
|
||||
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
|
||||
-- ceiling, and the void past its walls is the outside of a box, not open
|
||||
@@ -67,42 +76,78 @@ VoxelScene._modeColors = modeColors -- named for the suite
|
||||
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
|
||||
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
|
||||
-- mismatch the terrain bake had.
|
||||
--
|
||||
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
|
||||
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
|
||||
-- same way by the same seam; they are separate because the flat one also has to
|
||||
-- serve an indoor void and a battle's arena, which want a colour rather than a
|
||||
-- sky.
|
||||
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
|
||||
{ 64, 120, 192 }, { 16, 40, 80 } }
|
||||
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
|
||||
|
||||
-- fade across the approach to the top rung, so the sky arrives with the
|
||||
-- camera tween instead of popping in on the keypress
|
||||
-- the ramp as the display mode has it, which is the only form anything here
|
||||
-- should be reading it in
|
||||
local function skyRamp()
|
||||
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
|
||||
end
|
||||
|
||||
-- Full strength at every rung: the sky is painted wherever the diorama is.
|
||||
--
|
||||
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
|
||||
-- camera up from flat, and the sky comes up with it over the first few degrees
|
||||
-- rather than appearing whole on the keypress -- which is also what keeps a
|
||||
-- top-down camera, where there is no void worth speaking of, from painting one.
|
||||
local SKY_FADE_DEG = 8
|
||||
|
||||
local function skyStrength(angleRad)
|
||||
local deg = math.deg(angleRad or 0)
|
||||
local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below
|
||||
local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung
|
||||
if to <= from then return deg >= to and 1 or 0 end
|
||||
local t = (deg - from) / (to - from)
|
||||
if t < 0 then return 0 end
|
||||
if t > 1 then return 1 end
|
||||
return t
|
||||
if deg <= 0 then return 0 end
|
||||
local t = deg / SKY_FADE_DEG
|
||||
return t < 1 and t or 1
|
||||
end
|
||||
|
||||
-- One shade off the sky ramp, transformed by the display mode, as an
|
||||
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
|
||||
-- proper; 4 is its darkest, which is what an indoor void wants).
|
||||
function VoxelScene.skyShade(shade, alpha)
|
||||
local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
|
||||
local shades = skyRamp()
|
||||
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
|
||||
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
|
||||
end
|
||||
|
||||
-- The sky `map` stands under at strength `t`, or nil where there is no sky
|
||||
-- to paint: indoors, or with the horizon out of frame.
|
||||
--
|
||||
-- One flat colour, which is what a caller that only needs something to clear the
|
||||
-- void to wants -- the overworld battle's arena shot is one of those. The
|
||||
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
|
||||
function VoxelScene.skyColor(map, t)
|
||||
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
|
||||
if not t or t <= 0 then return nil end
|
||||
return VoxelScene.skyShade(SKY_SHADE, t)
|
||||
local sky = VoxelScene.skyShade(SKY_SHADE, t)
|
||||
-- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
|
||||
-- gold at dusk, navy at night -- so a battle staged on the map at
|
||||
-- midnight is under a midnight void, not a noon one. Free-roam is
|
||||
-- unchanged by this: Sky.dress overwrites the fill with the same value.
|
||||
local haze = Sky.haze()
|
||||
if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
|
||||
return sky
|
||||
end
|
||||
|
||||
-- The free-roam sky: the flat one above, dressed with the banded gradient
|
||||
-- (lib/Sky.lua).
|
||||
--
|
||||
-- Only here, and deliberately. This is the sky the walking camera stands under,
|
||||
-- where the horizon is a quarter of the way down the frame at the top rung and
|
||||
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
|
||||
-- placed camera whose horizon sits above the frame entirely, so it keeps the
|
||||
-- flat fill it has always had -- there is no gradient to see from down there,
|
||||
-- and the arena's look is not this rung's to change.
|
||||
local function skyFor(map)
|
||||
return VoxelScene.skyColor(map, skyStrength(Voxel.angle))
|
||||
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
|
||||
if not sky then return nil end
|
||||
return Sky.dress(sky)
|
||||
end
|
||||
|
||||
VoxelScene._skyFor = skyFor -- named for the suite
|
||||
@@ -170,6 +215,22 @@ local function frameFor(def, facing, phase, flip)
|
||||
return frame, mirror
|
||||
end
|
||||
|
||||
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
|
||||
-- looks from the south", which is where the orbit always stands; a
|
||||
-- first-person eye stands anywhere, so deep enough into the blend the
|
||||
-- facing is remapped to how the pose looks from THERE -- walk behind an
|
||||
-- NPC and their card wears the back sprite. Used by the camera draw and
|
||||
-- the sun pass BOTH: the card the sun stored and the transform a lit card
|
||||
-- reads its own shadowing with must describe the same frame, or the
|
||||
-- mirror-flip half of the pair asks the map about texels the sun filed
|
||||
-- under the other cheek.
|
||||
local function viewFacing(p)
|
||||
if FirstPerson.cardBlend() > 0.5 then
|
||||
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return p.facing
|
||||
end
|
||||
|
||||
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
|
||||
-- a decal: its current sprite frame as a single quad, flattened onto the
|
||||
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
|
||||
@@ -192,10 +253,22 @@ end
|
||||
-- Shared by the solid draw and the silhouette below, so the two can never
|
||||
-- drift apart -- a silhouette standing anywhere but exactly behind the
|
||||
-- figure would read as a second character.
|
||||
--
|
||||
-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed
|
||||
-- about its feet to face the eye (cylindrical billboarding). A south-facing
|
||||
-- card is invisible edge-on to an eye standing east of it, which no orbit
|
||||
-- camera could ever do and a first-person one does constantly. The blend
|
||||
-- carries one pose into the other -- the lean eases out as the yaw eases in
|
||||
-- -- and cardBlend is zero for every camera that is not the first-person
|
||||
-- rig, the battle's placed shot included, so nothing else moves.
|
||||
local function billboardMatrix(px, py, y, mirror)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
|
||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
||||
local b = FirstPerson.cardBlend()
|
||||
local m = Mat4.translate(px + 8, y, py + 8)
|
||||
if b > 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||
end
|
||||
m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
||||
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
||||
end
|
||||
@@ -205,6 +278,50 @@ local function billboardPull()
|
||||
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
||||
end
|
||||
|
||||
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
|
||||
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
|
||||
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
|
||||
-- local space with its feet on y = 0, and this stands it at its drawn
|
||||
-- position and tips it back by exactly the camera's pitch -- the same
|
||||
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
|
||||
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
|
||||
-- around him. No cell centring: unlike a character he is not standing on a
|
||||
-- cell, he is standing where he was drawn, which may straddle two.
|
||||
--
|
||||
-- First person turns him at the eye like the walkers (see billboardMatrix)
|
||||
-- -- about his own middle, because unlike a character card his local space
|
||||
-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about
|
||||
-- his edge would swing him off his seat. The width rode in on the record
|
||||
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
||||
local function figureMatrix(f, offX, offZ)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local b = FirstPerson.cardBlend()
|
||||
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
|
||||
local m = Mat4.translate(wx, f.y, wz)
|
||||
if b > 0 and f.w and f.w > 0 then
|
||||
local half = f.w / 2
|
||||
m = Mat4.mul(m, Mat4.translate(half, 0, 0))
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
|
||||
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
|
||||
end
|
||||
return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
end
|
||||
|
||||
-- What the sun sees: the same card UNLEANED and flattened, exactly as
|
||||
-- Voxel3D.casterMatrix does it for a character.
|
||||
local function figureCaster(f, offX, offZ)
|
||||
return Mat4.mul(
|
||||
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
|
||||
Mat4.scale(1, 1, 0))
|
||||
end
|
||||
|
||||
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
|
||||
local function eachFigure(map, offX, offZ, draw)
|
||||
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
|
||||
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw one posed entity. Returns true if 3D geometry carried it, false
|
||||
-- when nothing could be built and the caller should fall back.
|
||||
-- `colors` is the 4-color world palette the entity stands under in the SGB
|
||||
@@ -235,12 +352,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
|
||||
-- drift): lets the leaned-back head win against the wall it leans
|
||||
-- OVER while a character genuinely BEHIND a building is dozens of
|
||||
-- pixels deeper and still loses, so real occlusion works.
|
||||
-- the same card UNLEANED is what the sun saw (castShadows draws
|
||||
-- exactly this mesh), so that is where each vertex asks whether the
|
||||
-- light reached it -- see Voxel3D.draw
|
||||
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
|
||||
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
|
||||
-- vertex asks whether the light reached it; see ShadowMap.snug for why
|
||||
-- the lookup must match the stored transform to the letter
|
||||
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
|
||||
billboardPull(),
|
||||
Voxel3D.casterMatrix(px, py, y, mirror))
|
||||
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -258,7 +376,7 @@ VoxelScene.drawEntity = drawEntity
|
||||
-- mesh for it.
|
||||
local function drawGhost(p)
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if not mesh then return end
|
||||
local tex = p.sprite:resolveImage()
|
||||
@@ -331,17 +449,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 /
|
||||
@@ -379,12 +505,219 @@ local function posesOf(state, spriteColors)
|
||||
gh = groundAt(state.map, e.cellX, e.cellY),
|
||||
lift = e.py - vy, colors = colors,
|
||||
}
|
||||
if e == state.player then me = posed[#posed] end
|
||||
if e == state.player then
|
||||
me = posed[#posed]
|
||||
-- marked so the camera draw can leave the card out in first
|
||||
-- person, where it would fill the lens from inside; the SUN pass
|
||||
-- reads the same list and deliberately does not check the mark
|
||||
me.isPlayer = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return posed, me
|
||||
end
|
||||
|
||||
-- ------- the glint's drive
|
||||
--
|
||||
-- A reflection is something the VIEWPOINT does, so the window glint is fed
|
||||
-- by the camera's own travel rather than by a clock: its phase advances
|
||||
-- with distance covered and its strength fades in over a few steps of
|
||||
-- walking and back out within a beat of standing still. Stand still and
|
||||
-- the glass is still; move and the light crosses it.
|
||||
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
|
||||
-- texels (see the scene shader), so this is a FRACTION of a texel per world
|
||||
-- pixel walked -- one full pass of the glint across a pane per eight or so
|
||||
-- cells of travel, with no frame ever jumping it far enough to strobe.
|
||||
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
|
||||
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
|
||||
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
|
||||
|
||||
function VoxelScene.glintStep(g, cx, cy)
|
||||
local dist = 0
|
||||
if g.x then
|
||||
dist = math.abs(cx - g.x) + math.abs(cy - g.y)
|
||||
end
|
||||
g.x, g.y = cx, cy
|
||||
g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi)
|
||||
if dist > 0.05 then
|
||||
g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN)
|
||||
else
|
||||
g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT)
|
||||
end
|
||||
return g
|
||||
end
|
||||
|
||||
local glint = {}
|
||||
|
||||
-- ------- the cast
|
||||
--
|
||||
-- Everybody standing on the map: the walkers, and the authored FIGURES the
|
||||
-- tileset draws into its own furniture (they ARE characters as far as the
|
||||
-- artwork is concerned, just ones drawn by the tileset instead of by a
|
||||
-- sprite sheet, so they get the same lean and the same camera-ward pull).
|
||||
--
|
||||
-- One function because it is drawn TWICE and the two must be identical: once
|
||||
-- into the frame, and once into the water's reflection copy (see drawWater --
|
||||
-- Gen 1 draws people over the world, and water is world, so the cast cannot
|
||||
-- be composited before the water it has to appear in).
|
||||
--
|
||||
-- Characters carry no wireframe out here, whatever the V-GRID row says. The
|
||||
-- seams are what makes the WORLD read as built out of voxels, and the people
|
||||
-- walking around in it are the one thing that should read as drawn instead --
|
||||
-- a grid over a 16x16 sprite lands a line every couple of display pixels and
|
||||
-- turns a face into a mesh. (The battle pass makes the opposite call for its
|
||||
-- own combatants, deliberately -- see BattleBillboard.)
|
||||
--
|
||||
-- Sprite sheets until the figure pass: their texture coordinates mean
|
||||
-- nothing to the tileset-shaped glass mask, so the glass is off or the
|
||||
-- panes' atlas positions stripe the cast with lamplight at night.
|
||||
local function drawCast(state, posed, atlasFor)
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.seams(false)
|
||||
-- Characters, normally depth-tested: the camera-ward pull inside
|
||||
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||
-- character genuinely behind a building is far deeper and loses the
|
||||
-- test, so buildings and trees really occlude.
|
||||
--
|
||||
-- In first person two of them change: the player's own card is left out
|
||||
-- (the eye is standing in it), and every other card wears the frame its
|
||||
-- pose SHOWS this eye (viewFacing) rather than the one it shows the
|
||||
-- south. Both run through here, so the water's reflection copy -- drawn
|
||||
-- by this same function -- agrees with the frame to the pixel.
|
||||
local hideMe = FirstPerson.hidePlayer()
|
||||
for _, p in ipairs(posed) do
|
||||
if not (p.isPlayer and hideMe) then
|
||||
drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
||||
p.colors, p.lift)
|
||||
end
|
||||
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).
|
||||
-- ------- and why the flat draw happens FIRST while the world is curved
|
||||
--
|
||||
-- The reflective pass writes no depth -- it cannot, the depth canvas is
|
||||
-- detached for the length of it so the shader can READ it -- and it does its
|
||||
-- own depth test against that texture instead. That test asks whether
|
||||
-- something opaque is in front, and it answers correctly for every case but
|
||||
-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so
|
||||
-- no lake can hide another, and the pass simply paints them in mesh order.
|
||||
--
|
||||
-- On a flat world that never matters: every surface lies in the one plane
|
||||
-- at its own recessed height, and a farther sheet always lands farther down
|
||||
-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the
|
||||
-- square of its distance, so the far side of the map swings down and back
|
||||
-- up into the near field of view -- and a sheet of sea a hundred and fifty
|
||||
-- tiles away, drawn later in the same mesh, paints straight over the pond
|
||||
-- at the player's feet. Not a reflection of the far shore: the far shore
|
||||
-- itself, rasterised on top of the water in front of you.
|
||||
--
|
||||
-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the
|
||||
-- ordinary scene shader with depth writes on, and the reflective pass draws
|
||||
-- over the top of what survived: the depth buffer now holds the water
|
||||
-- surface, so the pass's own test throws the far sheet away, and the
|
||||
-- reflection COPY holds it too, so a ray grazing another part of the lake
|
||||
-- reads water rather than the void behind it.
|
||||
--
|
||||
-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY,
|
||||
-- and it stays off -- the reflective pass tests only against terrain, as it
|
||||
-- always did. Painting the surface into the depth texture turns the pass's
|
||||
-- test into a comparison of the surface against ITSELF, which asks the two
|
||||
-- rasterisations to agree to within interpolation error -- and on mobile
|
||||
-- GPUs they don't reliably (that fight is what put the Android port back on
|
||||
-- flat water). Confined to the curve there is no regression to reach: the
|
||||
-- flat world never had the far-shore bug in the first place.
|
||||
function VoxelScene.drawWater(draws, cast)
|
||||
-- prepass only under the bend; see the header
|
||||
local curved = (Voxel3D.curveK or 0) > 0
|
||||
if curved then
|
||||
for _, d in ipairs(draws) do
|
||||
Voxel3D.draw(d[1], d[2], d[3])
|
||||
end
|
||||
end
|
||||
local plain = not curved
|
||||
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 every pass after it runs
|
||||
-- with no shader and no depth test.
|
||||
Voxel3D.endWater()
|
||||
end
|
||||
-- the fallback flat draw -- unless the curve's prepass already put the
|
||||
-- same meshes down, in which case a bailed frame is already whole
|
||||
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
|
||||
@@ -406,6 +739,16 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||
-- standing perfectly still
|
||||
put(vw); put(vh)
|
||||
put(math.floor((V.require("VoxelState").angle or 0) * 512))
|
||||
-- the sun itself: the cycle swings the shear as the clock runs, and a map
|
||||
-- lit from somewhere new must be redrawn from there too. Quantised by the
|
||||
-- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
|
||||
-- few times a minute rather than every frame.
|
||||
put(math.floor(ShadowMap.KX * 128))
|
||||
put(math.floor(ShadowMap.KZ * 128))
|
||||
-- and the first-person head: the box is fitted around wherever it looks
|
||||
-- and the sprite cards swap frames as it circles them, so a turn on the
|
||||
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
|
||||
put(FirstPerson.signature())
|
||||
put(tostring(terrain))
|
||||
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
||||
for _, p in ipairs(posed) do
|
||||
@@ -429,7 +772,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
|
||||
@@ -440,24 +783,59 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- The water surface, which the terrain mesh no longer carries (it is its
|
||||
-- own reflective pass now -- see Water). The sun still has to see it, or
|
||||
-- the map the light records has a hole at every lake and the frustum's
|
||||
-- far plane answers for the surface a shoreline tree's shadow falls on.
|
||||
ShadowMap.draw(water, atlasFor(state.map), nil)
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- flower billboards live outside the terrain mesh (they draw after the
|
||||
-- characters, pulled -- see render), but the sun still sees them: a
|
||||
-- handful of cutouts per meadow, unlike the grass left out below
|
||||
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
|
||||
-- handful of cutouts per meadow, unlike the grass left out below.
|
||||
-- Every thin card from here down is SNUGGED toward the sun along its own
|
||||
-- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead
|
||||
-- of starting a bias-width away.
|
||||
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
-- From here down it is the CAST, marked as such in the map (see
|
||||
-- ShadowMap.sprites) so water can decline them: everything the world casts
|
||||
-- still shades a lake, a silhouette of somebody standing beside it does
|
||||
-- not. Ground, roofs and the characters themselves take them as before.
|
||||
ShadowMap.sprites(true)
|
||||
-- authored figures cast too, for the same reason the flowers do: a
|
||||
-- handful of cards per map, and a person with no shadow reads as pasted on
|
||||
eachFigure(state.map, 0, 0, function(mesh, _, caster)
|
||||
ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster))
|
||||
end)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
|
||||
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
|
||||
end)
|
||||
end
|
||||
for _, p in ipairs(posed) do
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
-- viewFacing, exactly as the camera draw picks it (see viewFacing for
|
||||
-- why the two passes must agree): in first person the sun's card
|
||||
-- swaps frame as the eye circles, which costs a redraw the signature
|
||||
-- already charges for (FirstPerson.signature) and keeps a card from
|
||||
-- fringing against a mirror-flipped record of itself
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if mesh then
|
||||
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
||||
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
|
||||
mirror))
|
||||
ShadowMap.snug(
|
||||
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
|
||||
mirror)))
|
||||
end
|
||||
end
|
||||
ShadowMap.sprites(false)
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -467,12 +845,31 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- return nil: the engine keeps the 2D path for the frame and
|
||||
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
||||
-- invisibly instead of freezing or tilting an empty stage.
|
||||
local terrain, nbMesh = VoxelScene.prefetch(state)
|
||||
local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state)
|
||||
if not terrain then return nil end
|
||||
|
||||
local cam = state.camera
|
||||
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
|
||||
|
||||
-- the hour's light, before anything is cast or drawn: point the shared
|
||||
-- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
|
||||
-- as dark as a cave at noon) and set the tint the scene shader multiplies
|
||||
-- every surface by. A CANOPY map (Viridian Forest) is the case between:
|
||||
-- the rig stays at noon and no sky is painted, but the hour's tint still
|
||||
-- falls through the leaves -- night reaches a forest floor.
|
||||
local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
|
||||
DayNight.applyRig(outdoor)
|
||||
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map))
|
||||
-- and the window glass: the tileset's own panes (found in its art --
|
||||
-- GlassMask), lit after dark. Outdoors only, like everything the clock
|
||||
-- touches, which also keeps any pane-shaped art in an interior tileset
|
||||
-- from picking up a glint.
|
||||
local GlassMask = V.require("GlassMask")
|
||||
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
|
||||
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
||||
local g = VoxelScene.glintStep(glint, cx, cy)
|
||||
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
|
||||
|
||||
local function atlasFor(map)
|
||||
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
|
||||
end
|
||||
@@ -485,7 +882,24 @@ 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)
|
||||
|
||||
-- The first-person rig, built (or blended) for this frame and handed to
|
||||
-- Voxel3D BEFORE either pass runs: the sun's box is fitted around this
|
||||
-- camera, and every card matrix asks it which way to turn. With the
|
||||
-- blend fully out the call clears the placed camera and the orbit is
|
||||
-- exactly what it always was. The scene centre it returns walks from
|
||||
-- the orbit's view centre into the head, so the curve's focus and the
|
||||
-- depth reference follow the camera actually in charge.
|
||||
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
if fpRig then cx, cy = fpCx, fpCy end
|
||||
|
||||
-- The sun's box, pushed along the first-person look so it covers the
|
||||
-- ground THIS camera sees (a no-op at blend zero): the orbit's fit
|
||||
-- reaches far north and barely south, which is right for every rung
|
||||
-- but a head free to face south.
|
||||
local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh)
|
||||
castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor,
|
||||
water, nbWater)
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return nil
|
||||
@@ -507,12 +921,45 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
if not Voxel3D.shadowsActive() then
|
||||
Voxel3D.beginShadows()
|
||||
for _, p in ipairs(posed) do
|
||||
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
||||
p.lift)
|
||||
end
|
||||
Voxel3D.endShadows()
|
||||
end
|
||||
|
||||
-- and the water over the top of it, reflecting everything just drawn plus
|
||||
-- the sky the frame opened with (see drawWater).
|
||||
--
|
||||
-- After the fallback decals deliberately: those are the stand-in drop
|
||||
-- shadows for a frame with no shadow map, they write no depth, and a
|
||||
-- lake would otherwise wear one as a black smear. Water covers them,
|
||||
-- which is the same answer the shadow map's own pass gives (see
|
||||
-- ShadowMap.sprites) -- people do not shadow water either way.
|
||||
local waterDraws = {}
|
||||
if water then
|
||||
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
|
||||
end
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
if nbWater and nbWater[i] then
|
||||
waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy) }
|
||||
end
|
||||
end
|
||||
-- the cast goes into the reflection copy only -- see drawWater for why it
|
||||
-- cannot be composited yet and why it is drawn through the same function
|
||||
-- the real pass below uses
|
||||
if #waterDraws > 0 then
|
||||
VoxelScene.drawWater(waterDraws, function()
|
||||
drawCast(state, posed, atlasFor)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
-- Sprite sheets from here to the figure pass: their texture coordinates
|
||||
-- mean nothing to the tileset-shaped glass mask, so the glass is off or
|
||||
-- the panes' atlas positions stripe the cast with lamplight at night
|
||||
Voxel3D.glass(false)
|
||||
|
||||
-- The player's silhouette goes down BEFORE the characters, so the only
|
||||
-- thing it can meet in the depth buffer is the WORLD -- terrain, buildings,
|
||||
-- trees. Drawn after the solid pass it would meet the player's own card
|
||||
@@ -520,20 +967,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- wrote it, so the silhouette would paint over the player at all times.
|
||||
-- Every character then draws on top as usual, which leaves the silhouette
|
||||
-- showing in exactly one situation: where the world hides them.
|
||||
if me then
|
||||
--
|
||||
-- Not in first person: the card it silhouettes is the one the camera is
|
||||
-- standing inside, and "the world is in front of the player" is every
|
||||
-- wall the player faces.
|
||||
if me and not FirstPerson.hidePlayer() then
|
||||
Voxel3D.beginGhost()
|
||||
drawGhost(me)
|
||||
Voxel3D.endGhost()
|
||||
end
|
||||
|
||||
-- characters, normally depth-tested: the camera-ward pull inside
|
||||
-- Characters carry no wireframe out here, whatever the V-GRID row says.
|
||||
-- The seams are what makes the WORLD read as built out of voxels, and
|
||||
-- the people walking around in it are the one thing that should read as
|
||||
-- drawn instead -- a grid over a 16x16 sprite lands a line every couple
|
||||
-- of display pixels and turns a face into a mesh. (The battle pass makes
|
||||
-- the opposite call for its own combatants, deliberately: that is a
|
||||
-- staged shot rather than the world being walked around in -- see
|
||||
-- BattleBillboard.)
|
||||
--
|
||||
-- Characters, normally depth-tested: the camera-ward pull inside
|
||||
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||
-- character genuinely behind a building is far deeper and loses the
|
||||
-- test, so buildings and trees really occlude.
|
||||
for _, p in ipairs(posed) do
|
||||
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
p.colors, p.lift)
|
||||
end
|
||||
drawCast(state, posed, atlasFor)
|
||||
-- tall grass last, pulled camera-ward exactly as far as the characters
|
||||
-- were (same per-vertex shader bias, so grass never drifts either):
|
||||
-- relative depth between a walker and the tuft row south of their feet
|
||||
@@ -558,11 +1015,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- ON, while the nearest flower of the cell south (+20) stays in front
|
||||
-- and keeps overdrawing their feet.
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
|
||||
-- flowers are snugged casters too, so they read their own shadowing
|
||||
-- through the same snugged transform the sun stored them with
|
||||
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
|
||||
fpull)
|
||||
fpull, ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull)
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
|
||||
return Voxel3D.endScene()
|
||||
|
||||
+25
-3
@@ -32,8 +32,18 @@ local Voxel = {}
|
||||
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
|
||||
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
|
||||
-- like, and two rungs may look the same while meaning different things.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
|
||||
--
|
||||
-- 1ST is the other rung that is more than an angle: the camera steps off its
|
||||
-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
|
||||
-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
|
||||
-- it hands over from -- because the tween in and out of first person starts
|
||||
-- from whatever the orbit shows, and the lowest rung is the one a dive into
|
||||
-- a head should start from. Everything angle-derived (the sky's fade, the
|
||||
-- billboard lean the blend eases away) reads that 75 while the first-person
|
||||
-- rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
|
||||
"1ST (EXPERIMENTAL)" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
@@ -43,6 +53,13 @@ function Voxel.isFull(level)
|
||||
return (level or Voxel.level) == Voxel.FULL_LEVEL
|
||||
end
|
||||
|
||||
-- the rung the first-person camera sits on, likewise
|
||||
Voxel.FP_LEVEL = 6
|
||||
|
||||
function Voxel.isFirstPerson(level)
|
||||
return (level or Voxel.level) == Voxel.FP_LEVEL
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
@@ -51,7 +68,12 @@ end
|
||||
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
|
||||
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
|
||||
-- row, which is where a preset that changes other rows belongs.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
|
||||
--
|
||||
-- 1ST is on the path: it changes the camera and only the camera, which is
|
||||
-- exactly what the key promises -- and the key is also the way back OUT of
|
||||
-- first person on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- menu is a trip.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
|
||||
+1380
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,16 @@
|
||||
-- the engine's TILT mode -- is engine plumbing driven by the records
|
||||
-- below. This file declares; lib/ draws.
|
||||
--
|
||||
-- Nothing here reaches collision, movement, triggers or scripts. Voxel
|
||||
-- mode is purely presentational: it changes what the world LOOKS like and
|
||||
-- nothing about what it IS.
|
||||
-- Voxel mode is presentational: it changes what the world LOOKS like and
|
||||
-- nothing about what it IS. ONE rung is the deliberate exception. 1ST --
|
||||
-- the first-person camera -- replaces the grid WALK with a free,
|
||||
-- camera-relative one while it is selected (lib/FreeMove.lua), because a
|
||||
-- head you can steer with a mouse demands feet that go where it looks.
|
||||
-- Even there the game is untouched: the walk asks the engine's own
|
||||
-- collision the same questions a grid step asks, keeps the player's
|
||||
-- logical cell synced, and fires the engine's own landing pipeline per
|
||||
-- cell crossed -- warps, encounters, ledges, gates and scripts all run
|
||||
-- exactly as themselves. Step off the rung and the grid walk is back.
|
||||
|
||||
local mod = ...
|
||||
|
||||
@@ -78,6 +85,13 @@ local ChunkMesher = V.require("ChunkMesher")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local OverworldBattle = V.require("OverworldBattle")
|
||||
local BattleExit = V.require("BattleExit")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
|
||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||
-- calls this, and it is defined further down with the settings it drives.
|
||||
@@ -157,6 +171,16 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- would fight anyone who changed one deliberately.
|
||||
applyFull(level)
|
||||
Voxel.update(dt, level)
|
||||
-- the first-person head, on the same tick: its blend in and out of the
|
||||
-- orbit, the mouse capture lifecycle, and the frame's stick-rate look.
|
||||
-- Unconditional like Voxel.update, because the blend has to keep easing
|
||||
-- OUT after the rung is left
|
||||
FirstPerson.update(dt)
|
||||
-- the day/night clock, on the same always-running tick: Pipelines.update
|
||||
-- runs whatever the level, so time passes with the mode off, through
|
||||
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
|
||||
-- would mid-walk
|
||||
DayNight.update(dt)
|
||||
-- The overworld battle rides this hook rather than owning a pipeline of
|
||||
-- its own, because it owns no pass of the FRAME: it draws under a battle
|
||||
-- screen the engine composites, which is not a stage the registry has.
|
||||
@@ -194,20 +218,34 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- a magnified low-res image, while the FX closures keep drawing in
|
||||
-- world-pixel units.
|
||||
local sw, sh = sceneSize(ctx)
|
||||
local canvas = VoxelScene.render(ctx.state, sw, sh,
|
||||
-- With AA on, the whole pass runs into a canvas BIGGER than the window
|
||||
-- and is folded back down at the end (see AntiAlias). Nothing between
|
||||
-- these two lines knows: every pass in the frame measures itself in the
|
||||
-- canvas it was handed, so the sky's dither, the water's march and the
|
||||
-- camera itself all come out the same picture at a higher sample rate.
|
||||
local rw, rh = AntiAlias.expand(sw, sh)
|
||||
local canvas = VoxelScene.render(ctx.state, rw, rh,
|
||||
ctx.vw, ctx.vh, ctx.paletteFor)
|
||||
if not canvas then return nil end -- fall back to the 2D path
|
||||
if Voxel3D.beginOverlay() then
|
||||
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
|
||||
-- they are drawing into the supersampled canvas alongside everything
|
||||
-- else -- so the scale goes up with it, or the "!" bubble lands the
|
||||
-- right place at half the size. project() already answers in canvas
|
||||
-- pixels, so only the scale needs saying.
|
||||
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
||||
ctx.scale)
|
||||
ctx.scale * AntiAlias.factor())
|
||||
Voxel3D.endOverlay()
|
||||
end
|
||||
return canvas
|
||||
-- and back to the window's own size, which is what the engine composites
|
||||
-- one canvas pixel to one display pixel. A pass-through when AA is off.
|
||||
return AntiAlias.resolve(canvas, sw, sh, "world")
|
||||
end,
|
||||
|
||||
invalidate = function()
|
||||
Voxel3D.invalidate()
|
||||
OverworldBattle.invalidate()
|
||||
AntiAlias.invalidate()
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
end,
|
||||
})
|
||||
@@ -274,23 +312,99 @@ applyFull = function(level)
|
||||
-- the horizon flat. The curve bends the world away from a walking player,
|
||||
-- which fights a fixed diorama framing
|
||||
WorldCurve.setting:setIndex(1, Game)
|
||||
-- and the water reflecting everything it can: FULL is the diorama at its
|
||||
-- most photographed, and a lake with the sky and the shoreline in it is
|
||||
-- most of what makes the model read as being outdoors
|
||||
Water.setting:setIndex(1, Game)
|
||||
-- and the view fitted to the window
|
||||
opts.zoom = 0
|
||||
Zoom.applyOptions(opts)
|
||||
-- battles on the map too: FULL means the whole mode, and a fight is where
|
||||
-- half of it is spent. Set rather than forced -- the row is gone from the
|
||||
-- menu while FULL is on, but a save that already had it off gets it on.
|
||||
-- half of it is spent. Set and then LET GO of -- unlike the rows above, both
|
||||
-- battle rows stay on the menu under FULL (see the rows hook), so this is
|
||||
-- where the preset puts them and not where they are held.
|
||||
OverworldBattle.setting:setIndex(1, Game)
|
||||
-- with both mons out there on it: BACK SPRITES keeps the player's own on the
|
||||
-- menu, which is the one part of the old screen FULL is least about. Set the
|
||||
-- same way, and changed back on the same row a keypress later.
|
||||
OverworldBattle.backSetting:setIndex(1, Game)
|
||||
-- and the battle screen the staged fight is composed for. WIDE re-lays that
|
||||
-- screen out on a 304x144 surface, which moves every anchor the arena camera
|
||||
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
|
||||
-- fights on, so the layout follows them.
|
||||
OverworldBattle.forceOG(Game)
|
||||
-- and the sky on the clock on the wall: FULL pins DAYTIME to SYNC. Unlike
|
||||
-- the rest of the preset this one IS held, not just set -- the row is off
|
||||
-- the menu while FULL owns it (the rows hook below), so a value changed
|
||||
-- under it could never be seen or changed back.
|
||||
DayNight.forceSync(Game)
|
||||
if Game.writeOptions then pcall(Game.writeOptions, Game) end
|
||||
end
|
||||
|
||||
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
|
||||
-- concerned: the 3D-BTL row, and nothing else.
|
||||
--
|
||||
-- It used to answer yes under FULL as well, on the grounds that FULL owned
|
||||
-- that row and switched it on. FULL no longer owns it -- the row stays on the
|
||||
-- menu under FULL and can be switched off there (see the rows hook) -- so that
|
||||
-- clause would now claim staged battles for a preset the player had just
|
||||
-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
|
||||
-- never staged. The row is the only thing that decides, which is what every
|
||||
-- other reader of this setting already believed: OverworldBattle.begin and
|
||||
-- wantsFront both gate on enabled() alone.
|
||||
--
|
||||
-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
|
||||
-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
|
||||
-- this mode's rows say ON on a machine without a depth buffer too, and a menu
|
||||
-- that claims 3D battles are on must not also offer the layout they cannot be
|
||||
-- drawn in.
|
||||
local function stagedBattles()
|
||||
return OverworldBattle.enabled()
|
||||
end
|
||||
|
||||
local SETTINGS = {
|
||||
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
||||
{ WorldCurve.setting,
|
||||
"Bend the world down over the horizon, Animal Crossing style." },
|
||||
{ Water.setting,
|
||||
"Reflections on water. FULL adds screen-space reflections of the "
|
||||
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
|
||||
.. "the sun and the moon alone, which is most of the look for a "
|
||||
.. "fraction of the cost." },
|
||||
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
|
||||
-- knobs; what a battle is drawn over, and how it is framed, are not that.
|
||||
{ OverworldBattle.setting,
|
||||
"Fight on the map: the battle draws over the nearest clear ground, "
|
||||
.. "shot over the shoulder with a slow parallax drift." },
|
||||
.. "shot over the shoulder with a slow parallax drift.",
|
||||
full = true },
|
||||
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
|
||||
-- off the engine draws the classic screen, which is this row's ON already,
|
||||
-- and a row that no longer decides anything is worse than no row.
|
||||
{ OverworldBattle.backSetting,
|
||||
"Keep your own Pokemon on the battle menu, seen from behind in its "
|
||||
.. "original slot, instead of standing it on the map facing the foe. "
|
||||
.. "The foe is still out there on its own tile.",
|
||||
when = function() return stagedBattles() end, full = true },
|
||||
{ DayNight.setting,
|
||||
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
|
||||
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
|
||||
.. "shadows, the sky and the light following -- or SYNC it to the "
|
||||
.. "clock on the wall, so Kanto's evening falls when yours does." },
|
||||
-- Marked `full` for the opposite reason the battle rows are: this is not a
|
||||
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
|
||||
-- the diorama, not a licence to spend four times the fill rate on the
|
||||
-- machine it happens to be running on, so it neither sets this nor takes
|
||||
-- the row away -- the player decides what their hardware can carry, from
|
||||
-- inside FULL like anywhere else.
|
||||
{ AntiAlias.setting,
|
||||
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
|
||||
.. "lips, a tree against the sky -- by rendering the diorama larger than "
|
||||
.. "the window and folding it back down. Every edge in the picture "
|
||||
.. "softens with them, the tileset's own texels included, so the diorama "
|
||||
.. "reads smoother rather than sharper. 2X costs half again as many "
|
||||
.. "pixels in each direction and 4X twice, which makes this the most "
|
||||
.. "expensive row in the mod.",
|
||||
full = true },
|
||||
}
|
||||
|
||||
local schema = {}
|
||||
@@ -306,6 +420,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,
|
||||
@@ -320,9 +435,12 @@ mod.options:define(schema)
|
||||
-- AND the engine's TILT on the same press.
|
||||
--
|
||||
-- Consequences worth being explicit about: while this mod is enabled, TILT
|
||||
-- (3) and GBC FX (5) are unreachable by key. Both are still reachable on
|
||||
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
|
||||
-- registry already forces it off whenever a world pipeline takes the pass.
|
||||
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
|
||||
-- menu too, where both rows are taken away and both values held at zero (see
|
||||
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
|
||||
-- flat fake of what this mode does for real, the registry already forces it
|
||||
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
|
||||
-- present pass over the top of the diorama. Uninstalling puts both back.
|
||||
--
|
||||
-- Everything the engine does around a pipeline hotkey has to happen here
|
||||
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
||||
@@ -335,6 +453,7 @@ local HOTKEYS = {
|
||||
["5"] = VoxelGrid.setting,
|
||||
["7"] = WorldCurve.setting,
|
||||
["8"] = OverworldBattle.setting,
|
||||
["9"] = Water.setting,
|
||||
}
|
||||
|
||||
do
|
||||
@@ -384,15 +503,20 @@ 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 keys
|
||||
-- parameterise the pass and leave the layout alone; the guard answers
|
||||
-- for all of them, so nothing here has to know which key it was.
|
||||
if stagedBattles() then OverworldBattle.forceOG(self) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -425,10 +549,12 @@ local function insertGrouped(out, extra)
|
||||
return out
|
||||
end
|
||||
|
||||
-- FULL owns every one of those settings, so while it is selected they are
|
||||
-- taken off the menu rather than left to be changed under it -- including
|
||||
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
|
||||
-- longer decides anything is worse than no row.
|
||||
-- FULL owns the settings that describe the LOOK, so while it is selected those
|
||||
-- are taken off the menu rather than left to be changed under it -- including
|
||||
-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
|
||||
-- decides anything is worse than no row.
|
||||
--
|
||||
-- The battle rows are the exception and they stay; see the rows hook.
|
||||
local function dropRow(out, id)
|
||||
for i = #out, 1, -1 do
|
||||
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
|
||||
@@ -436,17 +562,87 @@ local function dropRow(out, id)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- TILT and GBC FX are gone while this mod is installed
|
||||
--
|
||||
-- Both fight the diorama, and both were already half-taken: the mode's own key
|
||||
-- (3) forces them off on every press, and the registry switches TILT off
|
||||
-- whenever a world pipeline takes the pass. What was left was two rows the
|
||||
-- player could set and watch get reverted -- TILT is the flat fake of what
|
||||
-- this mode does for real, and GBC FX is a full-screen present pass over the
|
||||
-- top of the whole thing.
|
||||
--
|
||||
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
|
||||
-- Hiding a live setting is a trap: a save written before the mod was installed
|
||||
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
|
||||
-- back off. Pinned wherever the value could have arrived from -- the menu
|
||||
-- opening, a save being loaded or begun -- so there is no route by which one
|
||||
-- of them is on and unreachable.
|
||||
--
|
||||
-- Everything they did is still reachable: uninstall the mod and both rows are
|
||||
-- back, at whatever they were last set to.
|
||||
local function pinEngineFx(game)
|
||||
game = game or require("src.core.Game")
|
||||
local opts = game and game.save and game.save.options
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local changed = false
|
||||
if opts then
|
||||
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
|
||||
opts.tilt, opts.gbcfx = 0, 0
|
||||
end
|
||||
pcall(Tilt.setLevel, 0)
|
||||
pcall(GBCFX.setLevel, 0)
|
||||
if changed and game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end
|
||||
|
||||
-- call next() first and decorate what comes back, so every other mod's
|
||||
-- rows survive this one
|
||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
local out = next(game, rows)
|
||||
if type(out) ~= "table" then return out end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
if Voxel.isFull(Pipelines.level("voxel")) then
|
||||
return dropRow(out, "pipeline:tiltshift")
|
||||
-- ahead of every branch below, including FULL's early return: these two are
|
||||
-- off the menu whatever else this mod is or is not doing
|
||||
pinEngineFx(game)
|
||||
dropRow(out, "tilt")
|
||||
dropRow(out, "gbcfx")
|
||||
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
||||
-- one away. While a fight can be staged on the map, OG is the only layout it
|
||||
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
||||
-- and the row comes off the list on the same reasoning as the rows FULL owns:
|
||||
-- a row that no longer decides anything is worse than no row. Nothing is
|
||||
-- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
|
||||
-- keypress.
|
||||
if stagedBattles() then
|
||||
OverworldBattle.forceOG(game)
|
||||
dropRow(out, "battleLayout")
|
||||
end
|
||||
local full = Voxel.isFull(Pipelines.level("voxel"))
|
||||
if full then
|
||||
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
|
||||
-- horizon bend, the blur, the hour -- so those come off the menu and
|
||||
-- DAYTIME is held at SYNC while its row is unreachable.
|
||||
DayNight.forceSync(game)
|
||||
dropRow(out, "pipeline:tiltshift")
|
||||
end
|
||||
local extra = {}
|
||||
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
|
||||
for _, entry in ipairs(SETTINGS) do
|
||||
-- Two things decide whether a row is offered.
|
||||
--
|
||||
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
|
||||
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
|
||||
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
|
||||
-- applyFull); it does not hold them, so leaving them on the menu is the
|
||||
-- difference between a preset and a lock.
|
||||
--
|
||||
-- And a row whose own switch is off the table this frame (BACK SPRITES,
|
||||
-- which needs a staged fight to be about) is left off with it. The mod
|
||||
-- manager's page carries every one of them either way.
|
||||
local offered = (entry.full or not full)
|
||||
and (not entry.when or entry.when())
|
||||
if offered then extra[#extra + 1] = entry[1]:row() end
|
||||
end
|
||||
return insertGrouped(out, extra)
|
||||
end)
|
||||
|
||||
@@ -457,6 +653,15 @@ mod.events:on("mod.options_changed", function(payload)
|
||||
for _, entry in ipairs(SETTINGS) do
|
||||
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
|
||||
end
|
||||
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
|
||||
-- the OPTIONS row does. The manager persists its own value; this is the one
|
||||
-- that has to follow it.
|
||||
if stagedBattles() then OverworldBattle.forceOG() end
|
||||
-- and DAYTIME changed from the manager's page while FULL owns it snaps
|
||||
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
|
||||
-- not, and FULL's pin must hold against both
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
|
||||
end)
|
||||
|
||||
-- ------- keeping the geometry in step with the world
|
||||
@@ -539,7 +744,7 @@ mod.events:on("map.reloaded", function(payload)
|
||||
if mapId then ChunkMesher.invalidate(mapId) end
|
||||
end)
|
||||
|
||||
-- ------- FULL takes rows off the menu, so the menu has to notice
|
||||
-- ------- rows come and go, so the menu has to notice
|
||||
--
|
||||
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
|
||||
-- that list every frame. So stepping the VOXEL row onto or off FULL changed
|
||||
@@ -547,25 +752,40 @@ end)
|
||||
-- settings FULL owns stayed visible until the menu was closed and reopened,
|
||||
-- and a player who stepped off FULL could not see the rows come back.
|
||||
--
|
||||
-- Rebuilt in place, and only on a step that crosses FULL: every other rung
|
||||
-- returns the same list, and rebuilding on all of them would rerun every
|
||||
-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
|
||||
-- than reset, so it stays on the VOXEL row it was just used on instead of
|
||||
-- jumping to the top when the list below it shortens.
|
||||
-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL,
|
||||
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
|
||||
-- Every other rung returns the same list, and rebuilding on all of them would
|
||||
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
|
||||
-- clamped rather than reset, so it stays on the row it was just used on
|
||||
-- instead of jumping to the top when the list below it shortens.
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
if not OptionsMenu.dramaticShapeFullHook then
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local inner = OptionsMenu.update
|
||||
|
||||
local function idAt(menu, index)
|
||||
local row = menu.rows and menu.rows[index or 1]
|
||||
return type(row) == "table" and row.id or nil
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
local before = Pipelines.level("voxel")
|
||||
local hadBattles = OverworldBattle.enabled()
|
||||
local wasOn = idAt(self, self.index)
|
||||
inner(self, dt)
|
||||
local after = Pipelines.level("voxel")
|
||||
if after ~= before
|
||||
and (Voxel.isFull(before) or Voxel.isFull(after)) then
|
||||
local crossedFull = after ~= before
|
||||
and (Voxel.isFull(before) or Voxel.isFull(after))
|
||||
if crossedFull or OverworldBattle.enabled() ~= hadBattles then
|
||||
local rebuilt = OptionsMenu.new(self.game)
|
||||
self.rows = rebuilt.rows
|
||||
-- Follow the row the cursor was ON rather than the slot it was in:
|
||||
-- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
|
||||
-- otherwise slide the cursor onto the row under the one just used.
|
||||
for i = 1, #self.rows do
|
||||
if wasOn and idAt(self, i) == wasOn then self.index = i; break end
|
||||
end
|
||||
local cancel = #self.rows + 1
|
||||
if (self.index or 1) > cancel then self.index = cancel end
|
||||
end
|
||||
@@ -583,6 +803,32 @@ end
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- ------- the first-person rung's inputs and its walk
|
||||
--
|
||||
-- 1ST needs two things no other rung does, and each is a named seam:
|
||||
--
|
||||
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
|
||||
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
|
||||
-- on anything but the left pair), relative mouse motion (love.mousemoved --
|
||||
-- there is no Game handler to wrap; the engine's own callback only feeds
|
||||
-- the mouse-as-touch debug path, which stays untouched), the mouse buttons
|
||||
-- while the cursor is captured (A and B -- there is no cursor to click UI
|
||||
-- with), and any touch that lands off the overlay's controls (a drag on
|
||||
-- open screen is the look; the d-pad and buttons still go to
|
||||
-- TouchControls, whose own d-pad finger is also read back analog as the
|
||||
-- move vector). Every wrap forwards whatever it does not claim, and claims
|
||||
-- only while 1ST is actually driving.
|
||||
--
|
||||
-- FreeMove.install wraps OverworldState:handleInput -- the one choke point
|
||||
-- where the grid walk reads the pad, and the same seam the engine's own
|
||||
-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous
|
||||
-- and camera-relative; the player's logical cell stays synced and every
|
||||
-- per-cell consequence still runs through the engine's own machinery
|
||||
-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
|
||||
-- file argues the whole arrangement.
|
||||
FirstPerson.install()
|
||||
FreeMove.install()
|
||||
|
||||
-- The overworld's own pushBattle is the choke point for a wild encounter or
|
||||
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
|
||||
-- link battle, a script pushing a BattleState directly -- reaches this
|
||||
@@ -620,7 +866,71 @@ mod.events:on("battle.ended", function()
|
||||
OverworldBattle.finish()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.1.1"
|
||||
-- ------- and the way back out
|
||||
--
|
||||
-- The engine wipes INTO a battle with one of the original's eight transitions
|
||||
-- and cuts straight OUT of it. That cut is between two very different cameras
|
||||
-- in this mode, so while voxel mode is on the battle fades out, closes behind
|
||||
-- the black, and the map fades up. The two seams it needs -- BattleState:finish
|
||||
-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua.
|
||||
--
|
||||
-- Declared as a transitions record rather than a constant in that file, so the
|
||||
-- fade is retunable in data exactly like the eight wipes it answers, and a total
|
||||
-- conversion can make it as long or as short as its own pacing wants.
|
||||
mod.content.transitions:register(BattleExit.ID, {
|
||||
frames = BattleExit.FRAMES,
|
||||
})
|
||||
|
||||
BattleExit.install()
|
||||
|
||||
-- ------- and the hour on the flat world
|
||||
--
|
||||
-- The clock reaches the diorama through the voxel shader's own tint uniform,
|
||||
-- which the 2D tile path never runs -- so with the mode off, the same evening
|
||||
-- that fell on the diorama left the flat world at permanent noon. One clock,
|
||||
-- two worlds, one of them ignoring it. DayTint paints the same multiply over
|
||||
-- the composited flat world, between the world blit and the UI blit; the
|
||||
-- reasoning for that exact instant is in the file.
|
||||
DayTint.install()
|
||||
|
||||
-- ------- what time it is
|
||||
--
|
||||
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
|
||||
-- time it is in Kanto is a fact about that journey, like where the player is
|
||||
-- standing. Written on the engine's save.writing event -- the moment before
|
||||
-- the bytes hit disk -- and read back whenever a save is opened or begun. A
|
||||
-- save with no clock in it starts at day; that is DayNight.restore's
|
||||
-- fallback, and also the DAYTIME row's own default.
|
||||
mod.events:on("save.writing", function()
|
||||
DayNight.store()
|
||||
end)
|
||||
|
||||
mod.events:on("save.loaded", function()
|
||||
DayNight.restore()
|
||||
-- a save written before this mod was installed can carry TILT or GBC FX
|
||||
-- switched on, and their rows are not there to switch them back off (see
|
||||
-- pinEngineFx). Answered here rather than only when the menu opens, so a
|
||||
-- player who never opens it is not left playing under one.
|
||||
pinEngineFx()
|
||||
end)
|
||||
|
||||
mod.events:on("save.created", function()
|
||||
DayNight.restore()
|
||||
pinEngineFx()
|
||||
end)
|
||||
|
||||
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
|
||||
-- eternal "DAY" until a mod answers here; answering it hands the period to
|
||||
-- the map.palette hook (ctx.tod) and music.select, so a palette or music
|
||||
-- pack keyed to night works with this mod's clock for free. next() first: a
|
||||
-- mod loaded before this one that already moved the time keeps its answer.
|
||||
mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
local out = next(tod, ctx)
|
||||
if out ~= tod then return out end
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.5.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
|
||||
|
||||
+4
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.1.1",
|
||||
"version": "1.5.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GRAPHICS",
|
||||
"game_version": "0.0.0-dev || >=0.1.28 <2.0.0",
|
||||
"game_version": "0.0.0-dev || >=0.1.37 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -11,32 +11,43 @@ return {
|
||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
||||
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
||||
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
|
||||
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
|
||||
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
|
||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
|
||||
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
|
||||
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
|
||||
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||
"on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
|
||||
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
|
||||
},
|
||||
added = {
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
|
||||
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
|
||||
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
|
||||
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
|
||||
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
|
||||
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
|
||||
"a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
|
||||
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
|
||||
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
|
||||
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
|
||||
},
|
||||
known = {
|
||||
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
|
||||
"water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew",
|
||||
"WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water",
|
||||
"a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line",
|
||||
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
|
||||
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
|
||||
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
|
||||
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
|
||||
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
|
||||
"1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
|
||||
"in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
|
||||
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
|
||||
},
|
||||
compat = { engine = ">=0.1.28 <2.0.0", modApi = 2 },
|
||||
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
-- Driver: one scene, once per rung of the AA row.
|
||||
--
|
||||
-- The AA row is the one setting in this mod whose whole effect is a pixel
|
||||
-- wide, so it is also the one that cannot be judged from a description. This
|
||||
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
|
||||
-- them side by side, magnified, and the row is either doing something or it
|
||||
-- is not.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
|
||||
-- SHOT_DIR=<dir> lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
|
||||
-- AA_MAP map id (default VIRIDIAN_CITY)
|
||||
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
|
||||
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
|
||||
--
|
||||
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
|
||||
-- diagonal of a fence and a tree's silhouette against the sky are the edges
|
||||
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
|
||||
-- angle to the pixel grid.
|
||||
--
|
||||
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
|
||||
-- three shots differ ONLY by the row under test, or comparing them means
|
||||
-- nothing. The clock is pinned, the animated tile slots are frozen, the
|
||||
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
|
||||
-- (a gaussian over the frame would smear away the very edges being looked at).
|
||||
--
|
||||
-- Nothing here writes the player's options: the row is moved with
|
||||
-- ModSetting:sync, which moves the cached index and persists nothing.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
|
||||
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
|
||||
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
|
||||
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
|
||||
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
|
||||
if sf == "" then sf = "up" end
|
||||
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
local NPC = require("src.world.NPC")
|
||||
if not NPC.dramaticShapeAaFreeze then
|
||||
local inner = NPC.update
|
||||
function NPC:update(...)
|
||||
self.frozen = true
|
||||
return inner(self, ...)
|
||||
end
|
||||
NPC.dramaticShapeAaFreeze = true
|
||||
end
|
||||
pcall(love.math.setRandomSeed, 20260801)
|
||||
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function cameraStill()
|
||||
local o = game.overworld
|
||||
local c = o and o.camera
|
||||
if not c then return true end
|
||||
local lx, ly, held = nil, nil, 0
|
||||
for _ = 1, 300 do
|
||||
if c.x == lx and c.y == ly then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
lx, ly = c.x, c.y
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The camera PITCH, which is the one that caught this driver out. The tween
|
||||
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
|
||||
-- angle stops moving, so the first shot of a run came out at 67 degrees
|
||||
-- while the two after it were at 75 -- three frames that differ by the
|
||||
-- camera, in a comparison whose entire subject is a pixel.
|
||||
local function angleStill()
|
||||
local last, held = nil, 0
|
||||
for _ = 1, 600 do
|
||||
if Voxel.angle == last then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
last = Voxel.angle
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
angleStill()
|
||||
cameraStill()
|
||||
-- the sun map is only redrawn when its inputs move, and the AA row is not
|
||||
-- one of them -- so force one pass at the settled camera rather than
|
||||
-- comparing a frame against a map fitted a few hundredths of a pixel ago
|
||||
if ShadowMap.forget then ShadowMap.forget() end
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
DayNight.setting:sync("day")
|
||||
U.teleport(game, MAP, sx, sy, sf)
|
||||
Pipelines.setLevel("voxel", RUNG)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
|
||||
-- Warm up before the FIRST shot, not just between them.
|
||||
--
|
||||
-- Neighbour maps are requested from inside the render itself
|
||||
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
|
||||
-- means "nothing has been asked for yet", not "everything is here". The
|
||||
-- first capture of a run came out with the map beyond Viridian missing --
|
||||
-- a whole tree line absent from one frame of a three-way comparison, which
|
||||
-- looks exactly like the row under test doing something enormous. Settling
|
||||
-- twice lets the first render request the neighbourhood and the second
|
||||
-- drain it.
|
||||
settle()
|
||||
settle()
|
||||
|
||||
local shots, missed = 0, 0
|
||||
for _, samples in ipairs({ 0, 2, 4 }) do
|
||||
AntiAlias.setting:sync(samples)
|
||||
settle()
|
||||
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
|
||||
-- run disagree by more than the row could account for, this is what says
|
||||
-- which input moved -- it is how the camera-tween and the neighbour-mesh
|
||||
-- settles above were both found.
|
||||
if os.getenv("AA_TRACE") == "1" then
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local o = game.overworld
|
||||
local cw, chh = Voxel3D.size()
|
||||
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
|
||||
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
|
||||
Voxel3D.cell or -1, cw or 0, chh or 0,
|
||||
o and o.camera and o.camera.x or -1,
|
||||
o and o.camera and o.camera.y or -1,
|
||||
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
|
||||
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
|
||||
end
|
||||
local path = ("%s/aa_%d.png"):format(ROOT, samples)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
print(("[aa] %s samples=%d"):format(path, samples))
|
||||
else
|
||||
missed = missed + 1
|
||||
print("[aa] capture did not reach disk: " .. path)
|
||||
end
|
||||
end
|
||||
-- left where it was found, so a run cannot leak a rung into the next one
|
||||
AntiAlias.setting:sync(0)
|
||||
|
||||
print(("[aa] %d shots into %s (%d failed to reach disk)")
|
||||
:format(shots, ROOT, missed))
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Scratch driver: shots of the Bike Shop showroom, for the bicycle
|
||||
-- voxelization. Two viewpoints -- the north wall (the two bikes drawn
|
||||
-- INTO the wall band) and the showroom floor (the six standing bikes).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bike] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six
|
||||
-- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5
|
||||
local SCENES = {
|
||||
{ x = 2, y = 2, face = "up", label = "wall" },
|
||||
{ x = 3, y = 3, face = "up", label = "room" },
|
||||
{ x = 2, y = 4, face = "left", label = "floor" },
|
||||
{ x = 3, y = 6, face = "up", label = "wide" },
|
||||
-- the two toolboxes, cells (6,6) and (7,7)
|
||||
{ x = 5, y = 6, face = "right", label = "tools" },
|
||||
{ x = 6, y = 4, face = "down", label = "tools2" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[bike] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[bike] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Scratch driver: shots of Bill's desk, for the `bills_desk`
|
||||
-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house
|
||||
-- with its chair in the walkable cell (1,5) below it, so these are the
|
||||
-- angles you can actually stand at: head-on from the floor two cells
|
||||
-- south, and from either flank.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bills] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 6, face = "up", label = "headon" },
|
||||
{ x = 2, y = 6, face = "up", label = "headon_e" },
|
||||
{ x = 4, y = 5, face = "left", label = "east" },
|
||||
{ x = 0, y = 5, face = "right", label = "west" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[bills] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[bills] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Scratch driver: shots of the Celadon chief's house, for the display
|
||||
-- cabinet and long table voxelizations. Three viewpoints -- the
|
||||
-- cabinet rank along the north wall, the long table in the middle of
|
||||
-- the room, and a wide shot with both in frame.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/chief")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[chief] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- the cabinets occupy cells 2..5 of rows 0-1; the long table cells
|
||||
-- 2..5 of rows 3-4; the player walks rows 2 and 5
|
||||
local SCENES = {
|
||||
{ x = 3, y = 2, face = "up", label = "cabinets" },
|
||||
{ x = 5, y = 2, face = "up", label = "bookcase" },
|
||||
{ x = 3, y = 5, face = "up", label = "table" },
|
||||
{ x = 1, y = 5, face = "right", label = "wide" },
|
||||
-- the same rank on CELADON_MANSION_1F, where it stands against the
|
||||
-- interior partition and the grids start on an ODD tile row
|
||||
{ map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up",
|
||||
label = "mansion1f" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[chief] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[chief] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Driver: screenshot the day/night cycle.
|
||||
--
|
||||
-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
|
||||
-- mid-blend moments of the running cycle, a battle staged under the night
|
||||
-- sky, and a room at midnight -- which must look exactly like a room at
|
||||
-- noon, because indoors the clock does not reach.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local DayNight = lib.require("DayNight")
|
||||
|
||||
local function setTime(value, clock)
|
||||
DayNight.setting:sync(value)
|
||||
if clock then DayNight.clock = clock end
|
||||
DayNight.update(0)
|
||||
end
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
|
||||
-- straight to the top rung, whatever the persisted options say: pressing
|
||||
-- the key would walk the ladder RELATIVE to wherever a previous run left it
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
U.wait(150) -- let the camera ease over and the meshes land
|
||||
|
||||
-- ------- the four pins, one vantage
|
||||
for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
|
||||
setTime(phase)
|
||||
U.wait(30) -- a couple of rig steps + palette settle
|
||||
U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
|
||||
end
|
||||
|
||||
-- ------- the running cycle, mid-blend
|
||||
setTime("cycle", 560) -- day leaning into dusk
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
|
||||
setTime("cycle", 645) -- dusk falling into night
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
|
||||
|
||||
-- ------- golden hour on open ground, long shadows
|
||||
U.teleport(game, "ROUTE_1", 8, 12, "up")
|
||||
U.wait(120)
|
||||
setTime("cycle", 555) -- low western sun, shadows long and eastward
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/30_route1_low_sun.png")
|
||||
|
||||
-- ------- a fight staged under the night sky
|
||||
setTime("night")
|
||||
game.save.party = game.save.party or {}
|
||||
if #game.save.party == 0 then
|
||||
game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
|
||||
end
|
||||
-- any real trainer class: the dataset's ids vary by merge, so take the
|
||||
-- first one in stable order rather than guessing a name
|
||||
local classes = {}
|
||||
for id, rec in pairs(game.data.trainers) do
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_"
|
||||
and type(rec) == "table" and rec.parties and rec.parties[1] then
|
||||
classes[#classes + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(classes)
|
||||
local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
|
||||
if battle then
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(70)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||
U.shot(game, DIR .. "/40_night_battle.png")
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ------- and a room, where midnight must change nothing
|
||||
setTime("night")
|
||||
if game.data.maps.REDS_HOUSE_1F then
|
||||
U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
|
||||
U.wait(90)
|
||||
U.shot(game, DIR .. "/50_indoor_at_night.png")
|
||||
end
|
||||
|
||||
-- ------- the forest, where only the TINT of it reaches: night falls
|
||||
-- through the canopy, but there is no sky and the noon light stays put
|
||||
setTime("night")
|
||||
if game.data.maps.VIRIDIAN_FOREST then
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 17, 20, "up")
|
||||
U.wait(120)
|
||||
U.shot(game, DIR .. "/51_forest_night.png")
|
||||
setTime("day")
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/52_forest_day.png")
|
||||
end
|
||||
|
||||
setTime("day")
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
+2241
-26
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing
|
||||
-- in the player's head, billboards yawing to face it, the sky meeting the
|
||||
-- horizon, the shadow box following the look, water seen from eye level,
|
||||
-- and an interior with its figures.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/fpshots lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/fp")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[fp] DRAMATIC_SHAPE mod not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local DayNight = V.require("DayNight")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 0
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if FirstPerson.blend >= 1 and Voxel.ready
|
||||
and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a
|
||||
-- building footprint buries the eye in the geometry, which is what the
|
||||
-- first cut of every Pallet shot did.
|
||||
local function place(mapId, x, y)
|
||||
U.teleport(game, mapId, x, y, "down")
|
||||
local ow = game.stack:top()
|
||||
local map = ow and ow.map
|
||||
if not map or map:isWalkableCell(x, y) then return end
|
||||
for r = 1, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local cx, cy = x + dx, y + dy
|
||||
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then
|
||||
U.teleport(game, mapId, cx, cy, "down")
|
||||
print(("[fp] (%d,%d) not walkable; standing at (%d,%d)")
|
||||
:format(x, y, cx, cy))
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west
|
||||
local SCENES = {
|
||||
-- Pallet Town, mid-street: houses, the lab, NPCs -- the town seen
|
||||
-- from inside it, in each compass direction plus a diagonal
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" },
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" },
|
||||
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" },
|
||||
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4,
|
||||
label = "pallet_diag" },
|
||||
-- the shoreline: water at eye level, which is where the battle pass
|
||||
-- says a low placed camera reads the reflection wrong -- the shot
|
||||
-- decides whether 1ST keeps it
|
||||
{ map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
|
||||
-- looking up: the sky's bands and the horizon line
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = -math.rad(25), label = "pallet_skyward" },
|
||||
-- and down: the ground, the feet-level shadow
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = math.rad(45), label = "pallet_down" },
|
||||
-- Route 1: grass rows and ledges from inside them
|
||||
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
|
||||
-- an interior: the Center's counter, machines and couch figures
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
|
||||
label = "center_north" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2,
|
||||
label = "center_west" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
place(s.map, s.x, s.y)
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
FirstPerson.yaw = s.yaw
|
||||
FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
|
||||
U.wait(20)
|
||||
if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- one mid-blend shot: step the ladder onto 1ST from 75 and catch the
|
||||
-- dive halfway
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
settle()
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
for _ = 1, 300 do
|
||||
if FirstPerson.blend >= 0.5 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end
|
||||
|
||||
-- ------- the free walk, exercised
|
||||
--
|
||||
-- Hold forward with the head yawed off-grid and confirm the player
|
||||
-- GLIDES: the position moves along the look direction, lands off the
|
||||
-- 16px grid (which no grid step can do), and the logical cell follows.
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
settle()
|
||||
local ow = game.stack:top()
|
||||
local p = ow.player
|
||||
FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid
|
||||
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
|
||||
local x0, y0, c0x, c0y = p.px, p.py, p.cellX, p.cellY
|
||||
U.hold(game, "up", 90)
|
||||
U.wait(5)
|
||||
local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
|
||||
print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)")
|
||||
:format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY))
|
||||
print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s")
|
||||
:format(moved,
|
||||
tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
|
||||
tostring(math.abs(p.px - x0) > 8
|
||||
and math.abs(p.py - y0) > 8)))
|
||||
if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
|
||||
|
||||
print(("[fp] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Scratch driver: shots of the Pokemon Center healing machines behind
|
||||
-- the counter, for the center_heal_machine voxelization. The pair
|
||||
-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the
|
||||
-- nurse aisle (row 2) is the row you can actually face them from, and
|
||||
-- the public floor south of the counter gives the wide view.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[heal] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 2, face = "up", label = "west_head" },
|
||||
{ x = 2, y = 2, face = "up", label = "west_keyboard" },
|
||||
{ x = 3, y = 2, face = "left", label = "west_side" },
|
||||
{ x = 6, y = 2, face = "up", label = "east_head" },
|
||||
{ x = 3, y = 4, face = "up", label = "wide" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[heal] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[heal] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Scratch driver: shots of the house dining tables and stools, for the
|
||||
-- band-table + no-desk-part voxelization. Every generic home places the
|
||||
-- table at cells (3,3):(4,4) with four stools around it (Blue's house has
|
||||
-- Daisy seated at hers); Red's and the Copycat's ground floors place the
|
||||
-- same furniture one cell lower, with the potted plant CUTOUT standing on
|
||||
-- the tabletop -- the standee the table template must support, not
|
||||
-- swallow.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[housefurn] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- head on: below Blue's table looking north over a stool at it,
|
||||
-- Daisy seated at the left one
|
||||
{ map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" },
|
||||
-- from the east, table and both east stools in profile
|
||||
{ map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" },
|
||||
-- from the north wall looking south down over the tabletop
|
||||
{ map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" },
|
||||
-- close beside a stool: seat top, legs and the gap between them
|
||||
{ map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" },
|
||||
-- Red's table head on from the south: the plant cutout standing on
|
||||
-- the modelled tabletop
|
||||
{ map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" },
|
||||
-- and from the east along the stool row, plant in profile
|
||||
{ map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" },
|
||||
-- the Fan Club's four members' chairs round the boardroom table:
|
||||
-- from the south of the west pair, both stools stacked in profile
|
||||
{ map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up",
|
||||
label = "club_west_pair" },
|
||||
-- across the table from the west, both pairs and the octagon between
|
||||
{ map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right",
|
||||
label = "club_across" },
|
||||
-- close on the east pair from the north, looking down over the seats
|
||||
{ map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down",
|
||||
label = "club_over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[housefurn] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[housefurn] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash
|
||||
-- register voxelization. The register is drawn at cell (1,5) of the 4x4
|
||||
-- shop layout every Mart shares, in the middle of the counter's east arm,
|
||||
-- so these are the three angles you can actually stand at: head-on from
|
||||
-- the aisle, side-on from the east, and over the counter's south arm.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/register")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[mart] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ x = 1, y = 7, face = "up", label = "aisle" },
|
||||
{ x = 2, y = 5, face = "left", label = "side" },
|
||||
{ x = 2, y = 6, face = "left", label = "over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[mart] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[mart] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Probe: one overworld battle, held on the menu beat, shot large.
|
||||
--
|
||||
-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
love.math.setRandomSeed(20260727)
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 40),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
U.log("DRAMATIC_SHAPE lib:", tostring(lib))
|
||||
local Battles = lib and lib.require("OverworldBattle")
|
||||
U.log("OverworldBattle:", tostring(Battles))
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(120)
|
||||
|
||||
local classes = {}
|
||||
for id, rec in pairs(game.data.trainers) do
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_"
|
||||
and type(rec) == "table" and rec.parties and rec.parties[1] then
|
||||
classes[#classes + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(classes)
|
||||
U.log("class:", classes[1])
|
||||
local battle = BattleState.newTrainer(game, classes[1], 1)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
U.wait(70)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||
local arena = Battles and Battles.arena()
|
||||
U.log("arena:", arena and arena.shape or "none")
|
||||
U.shot(game, DIR .. "/probe_menu.png")
|
||||
for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end
|
||||
U.wait(90)
|
||||
U.shot(game, DIR .. "/probe_menu2.png")
|
||||
U.log("done -- " .. DIR)
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Scratch driver: shots of the potted plants, for the plant standee
|
||||
-- voxelization. Every Center places three side-by-side pairs on its
|
||||
-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6),
|
||||
-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id,
|
||||
-- same atlas) lines four of them along its hall at cells (12,10)..(15,10).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/plants")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[plant] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- east of the left pair, looking west along the bottom row: both
|
||||
-- plants in profile, crown overhang and pot silhouette side-on
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left",
|
||||
label = "pair_side" },
|
||||
-- north of the left pair, looking south down over the crowns
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down",
|
||||
label = "pair_over" },
|
||||
-- head on: standing below the middle pair looking north at it
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up",
|
||||
label = "pair_front" },
|
||||
-- close up beside the east pair's pot
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right",
|
||||
label = "close" },
|
||||
-- the Plateau lobby's row of four (MART tileset id), along the row
|
||||
{ map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right",
|
||||
label = "lobby_row" },
|
||||
-- and head on from the hall below
|
||||
{ map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up",
|
||||
label = "lobby_front" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[plant] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[plant] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Scratch driver: the Cerulean gym and the houses beside it, shot at
|
||||
-- several camera rungs with V-CURVE walked OFF..3, to see what the world
|
||||
-- bend does to a building's roof.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[roof] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of
|
||||
-- houses along the west side give a second, smaller roof in frame
|
||||
local SCENES = {
|
||||
{ map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" },
|
||||
{ map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" },
|
||||
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
for _, curve in ipairs({ 0, 3 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
WorldCurve.setting:setIndex(curve + 1, game)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[roof] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
end
|
||||
print(("[roof] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Scratch probe: how long do a building model's merged quads get?
|
||||
--
|
||||
-- A quad's longest world-space edge is what decides how far its CHORD
|
||||
-- falls below the world curve's parabola, so this is the number that says
|
||||
-- whether the bend can crack the mesh open.
|
||||
--
|
||||
-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY"
|
||||
U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"),
|
||||
tonumber(os.getenv("BUILD_Y") or "20"), "up")
|
||||
U.wait(30)
|
||||
|
||||
local V = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
V = V and V.lib
|
||||
local Structures = V and V.require("Structures")
|
||||
local ow = game.overworld
|
||||
if not (Structures and ow and ow.map) then
|
||||
print("[span] mod or map unavailable")
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
local S = Structures.forMap(ow.map)
|
||||
local hist, worst = {}, 0
|
||||
for _, q in ipairs(S.objectQuads) do
|
||||
local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
|
||||
- math.min(q[1][1], q[2][1], q[3][1], q[4][1])
|
||||
local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
- math.min(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
- math.min(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
local span = math.max(dx, dz, dy)
|
||||
local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16"
|
||||
or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64")))
|
||||
bucket = bucket .. (q.own and " bld" or " prop")
|
||||
hist[bucket] = (hist[bucket] or 0) + 1
|
||||
if span > worst then worst = span end
|
||||
end
|
||||
print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst))
|
||||
for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do
|
||||
for _, kind in ipairs({ " bld", " prop" }) do
|
||||
print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0))
|
||||
end
|
||||
end
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Scratch driver: one shot of every OTHER user of the round-hull builder
|
||||
-- (tree canopies, boulders, hedges, stumps, the Center planter), to check
|
||||
-- that the `can` class's base cut is the identity it is supposed to be for
|
||||
-- everything that does not ask for it.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/round")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then return end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" },
|
||||
{ map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" },
|
||||
{ map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" },
|
||||
{ map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[round] capture missed: " .. path) end
|
||||
else
|
||||
print("[round] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[round] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,218 @@
|
||||
-- Driver: propose and photograph several battle arenas for ONE map.
|
||||
--
|
||||
-- data/battle_arenas.lua holds a single authored spot per area, chosen by
|
||||
-- arena_pick's nearest-to-the-middle search and then looked at. This is the
|
||||
-- other half of that job: when the shipped spot is up for review, it lays out
|
||||
-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread
|
||||
-- along the map so the shortlist is places rather than neighbours -- and
|
||||
-- stages a real battle in each so they can be compared by eye.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love .
|
||||
--
|
||||
-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph.
|
||||
-- One `CAND` line per shot, ready to paste into the data file, plus a PNG
|
||||
-- named for its corner and shape.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates"
|
||||
local MAP = os.getenv("CAND_MAP") or "ROUTE_1"
|
||||
local WANT = tonumber(os.getenv("CAND_N") or "") or 5
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local Arena = lib.require("BattleArena")
|
||||
local Battles = lib.require("OverworldBattle")
|
||||
|
||||
U.teleport(game, MAP, 1, 1, "down")
|
||||
local map = game.overworld.map
|
||||
U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells))
|
||||
|
||||
-- ------- every arena the map can offer
|
||||
--
|
||||
-- BattleArena.search answers "the nearest one", which is the wrong question
|
||||
-- for a shortlist -- it returns one spot and hides the rest. So walk the
|
||||
-- same grid ourselves and keep them all, tagged with whether the pair would
|
||||
-- actually be SEEN there (Arena.clearance), because an obstructed spot is
|
||||
-- not a candidate no matter how good the ground looks.
|
||||
--
|
||||
-- The map's outermost cells are its CONNECTION BORDER -- the strip the
|
||||
-- neighbouring map is drawn into, walkable so the player can step across.
|
||||
-- Ground there passes every test and is still the wrong answer: a fight
|
||||
-- staged on it happens at the edge of the world with the border ring's tree
|
||||
-- wall at the mons' backs, and every spot in the strip looks like every
|
||||
-- other one. CAND_MARGIN keeps the shortlist on the route proper.
|
||||
local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2
|
||||
local cands = {}
|
||||
for _, shape in ipairs(Arena.SHAPES) do
|
||||
for y = MARGIN, map.heightCells - shape.h - MARGIN do
|
||||
for x = MARGIN, map.widthCells - shape.w - MARGIN do
|
||||
local fits = true
|
||||
for cy = y, y + shape.h - 1 do
|
||||
for cx = x, x + shape.w - 1 do
|
||||
if not Arena.openCell(map, cx, cy, false) then fits = false break end
|
||||
end
|
||||
if not fits then break end
|
||||
end
|
||||
if fits then
|
||||
local a = Arena.at(x, y, shape.id)
|
||||
if a and Arena.clearance(map, a) then
|
||||
cands[#cands + 1] = { x = x, y = y, shape = shape.id,
|
||||
mx = x + (shape.w - 1) / 2,
|
||||
my = y + (shape.h - 1) / 2 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("%d clear arenas on %s"):format(#cands, MAP))
|
||||
if #cands == 0 then U.log("done -- nothing to propose") return end
|
||||
|
||||
for _, c in ipairs(cands) do
|
||||
U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape))
|
||||
end
|
||||
|
||||
-- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead
|
||||
-- of the spread one. The spread is a first pass over ground the clearance
|
||||
-- test approved, and that test measures terrain height along the sightline
|
||||
-- only -- it has no opinion on a hedge sitting in the apron row between the
|
||||
-- camera and the near mon, which is the failure that keeps turning up. So
|
||||
-- the loop is: spread, look, then re-shoot the survivors and the
|
||||
-- replacements by hand.
|
||||
local explicit = os.getenv("CAND_AT")
|
||||
if explicit and explicit ~= "" then
|
||||
local list = {}
|
||||
for spot in explicit:gmatch("[^;]+") do
|
||||
local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$")
|
||||
if x then
|
||||
list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s }
|
||||
else
|
||||
U.log("BAD CAND_AT entry: " .. spot)
|
||||
end
|
||||
end
|
||||
cands = list
|
||||
WANT = #list
|
||||
U.log(("%d spots given explicitly"):format(#list))
|
||||
end
|
||||
|
||||
local function shapeOf(id)
|
||||
for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end
|
||||
end
|
||||
for _, c in ipairs(cands) do
|
||||
local s = shapeOf(c.shape)
|
||||
c.mx = c.x + ((s and s.w or 1) - 1) / 2
|
||||
c.my = c.y + ((s and s.h or 1) - 1) / 2
|
||||
end
|
||||
|
||||
-- ------- thin them down to a shortlist that is actually a CHOICE
|
||||
--
|
||||
-- Adjacent corners are the same patch of ground shifted a cell, so a naive
|
||||
-- top-N is five photographs of one place, and pure farthest-point selection
|
||||
-- goes straight to the extremes -- which on a route means the ends, where
|
||||
-- the ground is emptiest and the shots are least distinguishable.
|
||||
--
|
||||
-- So: spread along the route's LONG AXIS, one pick per band, and within a
|
||||
-- band take the spot nearest the middle of the road. The road is measured
|
||||
-- rather than assumed -- the median cross-axis position of everywhere a
|
||||
-- fight fits IS the lane, on a map whose walkable ground is mostly lane.
|
||||
local horizontal = map.widthCells > map.heightCells
|
||||
local function along(c) return horizontal and c.mx or c.my end
|
||||
local function across(c) return horizontal and c.my or c.mx end
|
||||
|
||||
local xs = {}
|
||||
for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end
|
||||
table.sort(xs)
|
||||
local road = xs[math.ceil(#xs / 2)]
|
||||
U.log(("road runs %s, centre of the lane is %s = %.1f")
|
||||
:format(horizontal and "east-west" or "north-south",
|
||||
horizontal and "y" or "x", road))
|
||||
|
||||
local lo, hi = math.huge, -math.huge
|
||||
for _, c in ipairs(cands) do
|
||||
lo, hi = math.min(lo, along(c)), math.max(hi, along(c))
|
||||
end
|
||||
|
||||
-- an explicit shortlist is already the answer; the spread below would only
|
||||
-- thin it, and its overlap rule would silently drop two spots deliberately
|
||||
-- asked for a cell apart
|
||||
local picked, taken = {}, {}
|
||||
for band = 1, (explicit and explicit ~= "") and 0 or WANT do
|
||||
-- band centres, not band edges: the first and last picks sit inside the
|
||||
-- route rather than on its two connection mouths
|
||||
local target = lo + (hi - lo) * (band - 0.5) / WANT
|
||||
local best, bestScore
|
||||
for _, c in ipairs(cands) do
|
||||
if not taken[c] then
|
||||
local da = along(c) - target
|
||||
local dr = across(c) - road
|
||||
-- distance from the band centre, plus a heavier penalty for being off
|
||||
-- the lane; wide arenas are worth a detour, being the shot this mode
|
||||
-- is framed for
|
||||
local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0)
|
||||
if not bestScore or score < bestScore then best, bestScore = c, score end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
picked[#picked + 1] = best
|
||||
-- everything overlapping the pick is off the table, so two bands whose
|
||||
-- best spots touch cannot return the same patch of ground twice
|
||||
for _, c in ipairs(cands) do
|
||||
if math.abs(along(c) - along(best)) < 3
|
||||
and math.abs(across(c) - across(best)) < 3 then
|
||||
taken[c] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #picked == 0 then picked = cands end
|
||||
|
||||
-- north to south (or west to east), so the filenames read along the route
|
||||
table.sort(picked, function(a, b)
|
||||
if along(a) ~= along(b) then return along(a) < along(b) end
|
||||
return across(a) < across(b)
|
||||
end)
|
||||
|
||||
for i, c in ipairs(picked) do
|
||||
U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },")
|
||||
:format(i, MAP, c.x, c.y, c.shape))
|
||||
-- forced through the authored-entry seam, so what gets staged is exactly
|
||||
-- this spot rather than whatever the search would pick from the player's
|
||||
-- cell
|
||||
Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape })
|
||||
local staged = Arena.find(map, 0, 0, false)
|
||||
if not staged then
|
||||
U.log(("SKIP %d -- override did not stage"):format(i))
|
||||
else
|
||||
game.overworld.player.cellX = staged.playerCell[1]
|
||||
game.overworld.player.cellY = staged.playerCell[2]
|
||||
U.wait(90) -- let the meshes land
|
||||
local battle = BattleState.newWild(game, "NIDORINO", 20)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(70)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||
local got = Battles.arena()
|
||||
U.log(("SHOT %d staged at %s,%s"):format(i, tostring(got and got.x),
|
||||
tostring(got and got.y)))
|
||||
U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png")
|
||||
:format(DIR, i, MAP:lower(), c.x, c.y, c.shape))
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
Arena.setOverride(MAP, nil)
|
||||
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Scratch driver: shots of the `bookcase` class across the tilesets that
|
||||
-- pin it, for the shelf-front relief. Two of them are NOT shelves --
|
||||
-- the League's gate walls and the terraces on PLATEAU -- and are here as
|
||||
-- the control: their courses run edge to edge, so nothing should sink.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[shelf] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" },
|
||||
{ map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" },
|
||||
{ map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" },
|
||||
{ map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" },
|
||||
{ map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" },
|
||||
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" },
|
||||
-- the controls, both of them tilesets that borrow the collapse for
|
||||
-- something that is NOT a shelf and say so with `bookcase_relief =
|
||||
-- false`: the League's masonry gate walls, and Bill's transporter
|
||||
-- drums. Nothing in either may move.
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" },
|
||||
{ map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" },
|
||||
-- the house shelves: pinned `desk`, NOT `bookcase`, so they go
|
||||
-- through the world mesher's box fold and this relief never reaches
|
||||
-- them. Here to show the gap.
|
||||
{ map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" },
|
||||
{ map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
-- twice: the first load of the session has no mesh to settle against
|
||||
pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[shelf] capture missed: " .. path) end
|
||||
else
|
||||
print("[shelf] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[shelf] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt.
|
||||
-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's
|
||||
-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at
|
||||
-- (4,1), and one each in the two ship-interior houses at (7,7).
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[ship] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" },
|
||||
{ map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" },
|
||||
{ map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" },
|
||||
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" },
|
||||
{ map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
|
||||
if ok then
|
||||
Pipelines.setLevel("voxel", 5)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[ship] capture missed: " .. path) end
|
||||
else
|
||||
print("[ship] teleport failed: " .. s.map)
|
||||
end
|
||||
end
|
||||
print(("[ship] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Driver: prove the banded sky still paints from the ramp texture.
|
||||
--
|
||||
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
|
||||
-- Android delivered only its first few slots and painted the rest of the sky
|
||||
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
|
||||
-- three things that swap could have broken, on the machine it CAN be checked
|
||||
-- on: that the shader still compiles, that the ramp is built and is one texel
|
||||
-- per band, and that what lands on screen is still a gradient that pales
|
||||
-- downward rather than a flat plate or a black one.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local Sky = lib.require("Sky")
|
||||
local DayNight = lib.require("DayNight")
|
||||
|
||||
-- which copy of the mod is actually live: only the ramp build has this
|
||||
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
|
||||
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
|
||||
require("src.render.Pipelines").setLevel("voxel", 5)
|
||||
U.wait(150)
|
||||
|
||||
for _, phase in ipairs({ "day", "dusk", "night" }) do
|
||||
DayNight.setting:sync(phase)
|
||||
DayNight.update(0)
|
||||
U.wait(30)
|
||||
|
||||
local bands = Sky.bands()
|
||||
local ramp = Sky._rampFor and Sky._rampFor(bands)
|
||||
local w = ramp and ramp:getWidth() or -1
|
||||
U.log(("%s: %d bands, ramp %dx%d"):format(
|
||||
phase, #bands, w, ramp and ramp:getHeight() or -1))
|
||||
-- one texel per band is the whole contract: the shader divides by `count`
|
||||
-- and samples texel centres, so a ramp of any other width samples between
|
||||
-- bands or off the end
|
||||
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
|
||||
-- and the ramp is the ramp: the same table gives the same image back
|
||||
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
|
||||
|
||||
U.shot(game, ("%s/%s.png"):format(DIR, phase))
|
||||
end
|
||||
|
||||
DayNight.setting:sync("day")
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can
|
||||
-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell
|
||||
-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's
|
||||
-- platform. Even columns are open floor, so the player can be parked
|
||||
-- between two cans and look along a row.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/cans")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[can] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
-- head on into the middle of the field, cans left, right and ahead
|
||||
{ x = 4, y = 12, face = "up", label = "field" },
|
||||
-- close up: standing between two cans of the bottom row
|
||||
{ x = 2, y = 11, face = "left", label = "close" },
|
||||
-- along the row, so the cans line up in depth
|
||||
{ x = 4, y = 13, face = "up", label = "row" },
|
||||
-- from the north, looking back down over all three rows
|
||||
{ x = 4, y = 6, face = "down", label = "over" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs({ 3, 5 }) do
|
||||
U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
|
||||
game.capturePath = path
|
||||
U.wait(6)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[can] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
print(("[can] %d shots into %s"):format(shots, ROOT))
|
||||
end
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Driver: isolate the diagonal banding on flat lit geometry.
|
||||
--
|
||||
-- Shoots the SAME stand point four ways in ONE launch, so palette, camera
|
||||
-- and framing are identical and the only variable is the sun pass:
|
||||
--
|
||||
-- _on shadows as shipped
|
||||
-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it
|
||||
-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off
|
||||
--
|
||||
-- and prints, from the frustum the run actually fitted, how much depth
|
||||
-- slack each face orientation NEEDS against the slack it is given. A face
|
||||
-- whose need exceeds the slack shadows itself in a moire -- acne -- which
|
||||
-- reads as diagonal banding, since the ramp it aliases against runs along
|
||||
-- the light's frame and the sun sits southeast.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- ACNE_MAP map id (default VIRIDIAN_CITY)
|
||||
-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall)
|
||||
-- ACNE_LEVELS comma list of voxel levels (default "3")
|
||||
-- ACNE_FLAT comma list of constant-bias values to compare against
|
||||
-- (default "1" -- what shipped before SLOPE existed)
|
||||
-- SHOT_DIR output directory, must exist (default "shots")
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local SPEED = math.max(1,
|
||||
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
|
||||
local function wait(n) U.wait(n * SPEED) end
|
||||
|
||||
local DIR = os.getenv("SHOT_DIR") or "shots"
|
||||
local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY"
|
||||
local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up")
|
||||
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
|
||||
facing = (facing ~= "" and facing) or "up"
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Mat4 = V.require("Mat4")
|
||||
|
||||
local levels = {}
|
||||
for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do
|
||||
levels[#levels + 1] = tonumber(n)
|
||||
end
|
||||
local flats = {}
|
||||
for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do
|
||||
flats[#flats + 1] = tonumber(n)
|
||||
end
|
||||
|
||||
-- ---- what the frustum this run fitted asks of the bias, per face
|
||||
local FACES = {
|
||||
{ "top +Y", 0, 1, 0 },
|
||||
{ "south +Z", 0, 0, 1 },
|
||||
{ "east +X", 1, 0, 0 },
|
||||
{ "west -X", -1, 0, 0 },
|
||||
{ "north -Z", 0, 0, -1 },
|
||||
{ "roof 45 N", 0, 0.7071, -0.7071 },
|
||||
{ "roof 45 S", 0, 0.7071, 0.7071 },
|
||||
}
|
||||
|
||||
local function report(tag)
|
||||
local e = ShadowMap.extent
|
||||
if not e then print("[acne] " .. tag .. ": no frustum yet") return end
|
||||
local res = ShadowMap.res
|
||||
local texX, texY, depth = e[1] / res, e[2] / res, e[3]
|
||||
local d = ShadowMap.sunDir()
|
||||
local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 })
|
||||
local R = { view[1], view[2], view[3] }
|
||||
local Up = { view[5], view[6], view[7] }
|
||||
local F = { -view[9], -view[10], -view[11] }
|
||||
print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f"
|
||||
.. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)")
|
||||
:format(tag, res, e[1], e[2], depth, texX, texY,
|
||||
ShadowMap.slack, ShadowMap.bias))
|
||||
for _, fc in ipairs(FACES) do
|
||||
local n = { fc[2], fc[3], fc[4] }
|
||||
local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3]
|
||||
local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3]
|
||||
local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3]
|
||||
-- a face the sun cannot see never samples its own depth, so it
|
||||
-- cannot alias -- FACE_SHADE darkens those and the map is moot
|
||||
if nf < 0 then
|
||||
-- the plane's depth gradient in light space, times the half-texel
|
||||
-- the 2x2 filter reaches out on each axis
|
||||
local need = 0.5 * (math.abs(nr / nf) * texX
|
||||
+ math.abs(nu / nf) * texY)
|
||||
print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s")
|
||||
:format(fc[1], math.abs(nf),
|
||||
math.sqrt((nr/nf)^2 + (nu/nf)^2), need,
|
||||
need > ShadowMap.slack and "<-- ACNE" or "ok"))
|
||||
else
|
||||
print(("[acne] %s unlit (sun behind it)"):format(fc[1]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.reset()
|
||||
local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0)
|
||||
for _ = 1, math.abs(steps) do
|
||||
Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
|
||||
end
|
||||
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
|
||||
wait(20)
|
||||
do
|
||||
local vw, vh = game.renderer:worldViewSize()
|
||||
print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d")
|
||||
:format(mapId, sx, sy, facing, vw, vh, Zoom.offset))
|
||||
end
|
||||
|
||||
local shipped = Voxel3D.SHADOW_ALPHA
|
||||
local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE
|
||||
|
||||
-- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid
|
||||
-- on/off), so a band can be attributed to one of them rather than
|
||||
-- guessed at. The grid is the other thing in this pass that draws
|
||||
-- regular lines, and at a grazing angle it moires.
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local matrix = os.getenv("ACNE_MATRIX") == "1"
|
||||
|
||||
local function shoot(name)
|
||||
game.capturePath = ("%s/acne_%s.png"):format(DIR, name)
|
||||
wait(4)
|
||||
end
|
||||
|
||||
for _, level in ipairs(levels) do
|
||||
Pipelines.setLevel("voxel", level)
|
||||
wait(30) -- outlast the camera tween
|
||||
local label = Pipelines.levelLabel("voxel", level) or level
|
||||
print(("[acne] --- level %s shadowsActive=%s")
|
||||
:format(label, tostring(Voxel3D.shadowsActive())))
|
||||
report("as shipped")
|
||||
shoot(("%s_v%s_on"):format(mapId, label))
|
||||
|
||||
if matrix then
|
||||
for _, grid in ipairs({ true, false }) do
|
||||
VoxelGrid.sync(grid)
|
||||
for _, sun in ipairs({ true, false }) do
|
||||
Voxel3D.SHADOW_ALPHA = sun and shipped or 0
|
||||
wait(8)
|
||||
shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label,
|
||||
grid and "1" or "0", sun and "1" or "0"))
|
||||
end
|
||||
end
|
||||
Voxel3D.SHADOW_ALPHA = shipped
|
||||
end
|
||||
|
||||
-- the sun pass still runs; the main pass simply stops reading it
|
||||
Voxel3D.SHADOW_ALPHA = 0
|
||||
wait(6)
|
||||
shoot(("%s_v%s_off"):format(mapId, label))
|
||||
Voxel3D.SHADOW_ALPHA = shipped
|
||||
|
||||
for _, b in ipairs(flats) do
|
||||
ShadowMap.BIAS, ShadowMap.SLOPE = b, 0
|
||||
ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias
|
||||
wait(8)
|
||||
report(("flat %.1f"):format(b))
|
||||
shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p")))
|
||||
end
|
||||
ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope
|
||||
ShadowMap.invalidate()
|
||||
wait(6)
|
||||
end
|
||||
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
wait(5)
|
||||
print("[acne] done")
|
||||
end
|
||||
@@ -0,0 +1,246 @@
|
||||
-- Driver: the A/B performance benchmark.
|
||||
--
|
||||
-- One scripted, deterministic session that exercises the three things the
|
||||
-- mod is slow at, and writes the numbers to a JSON file so two runs can be
|
||||
-- diffed:
|
||||
--
|
||||
-- 1. LOADING IN -- engaging the mode from flat: shader compiles,
|
||||
-- canvas allocations, the first map's mesh, its
|
||||
-- atlas bakes and its glass mask, all at once.
|
||||
-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold
|
||||
-- caches, then walking the SAME route again with
|
||||
-- them warm. The gap between the two is the
|
||||
-- complaint; closing it is the fix.
|
||||
-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree
|
||||
-- rung puts the horizon in frame, which triples the
|
||||
-- sun frustum and hands the sky its disc to draw.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \
|
||||
-- DS_PERF=1 BENCH_TAG=baseline lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing
|
||||
-- BENCH_TAG output name, ds_bench/<tag>.json (default "run")
|
||||
-- BENCH_HOLD frames to walk per leg (default 900)
|
||||
--
|
||||
-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is
|
||||
-- measuring the weather:
|
||||
--
|
||||
-- * VSYNC OFF. With it on every frame costs exactly one refresh interval
|
||||
-- and the whole exercise reads as 16.7ms flat, saving or no saving.
|
||||
-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun
|
||||
-- angle, the shadow frustum and whether windows are lit -- so an
|
||||
-- unpinned run compares two different scenes.
|
||||
-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges
|
||||
-- its frames to whichever map the script thought it was on. Stubbed on
|
||||
-- the state class for this process only; nothing is written to disk.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local TAG = os.getenv("BENCH_TAG") or "run"
|
||||
-- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that
|
||||
-- means to reach Viridian needs about 1200 -- short of that the "new
|
||||
-- area" the benchmark is named for never gets entered.
|
||||
local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300)
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local Perf = V.require("Perf")
|
||||
if not Perf.enabled then
|
||||
print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing")
|
||||
return
|
||||
end
|
||||
local loadBytes = V.loadBytes
|
||||
local Structures = V.require("Structures")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Buildings = V.require("Buildings")
|
||||
local DayNight = V.require("DayNight")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
|
||||
-- module internals worth naming that the mod does not time itself
|
||||
Perf.wrap(Structures, "forMap", "Structures.forMap")
|
||||
Perf.wrap(Buildings, "build", "Buildings.build")
|
||||
Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump")
|
||||
Perf.wrap(VoxelScene, "render", "VoxelScene.render")
|
||||
|
||||
if love.window and love.window.setVSync then
|
||||
pcall(love.window.setVSync, 0)
|
||||
end
|
||||
DayNight.setting:sync("day")
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
-- ---- the run ------------------------------------------------------
|
||||
|
||||
local function seg(name)
|
||||
Perf.setSegment(name)
|
||||
-- the frame that STRADDLES a segment boundary belongs to neither: it
|
||||
-- carries the teleport, the level change or the report print that
|
||||
-- opened it, and charging that to the new segment libels it
|
||||
Perf.resync()
|
||||
end
|
||||
|
||||
local function settle(frames)
|
||||
Perf.setSegment(nil)
|
||||
U.wait(frames or 60)
|
||||
Perf.resync()
|
||||
end
|
||||
|
||||
-- Hold a direction, attributing each frame to the map the player is
|
||||
-- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of
|
||||
-- the walk, so the segment has to follow the player rather than the
|
||||
-- script's idea of where they are.
|
||||
local function walk(dir, frames, prefix)
|
||||
local last = nil
|
||||
for _ = 1, frames do
|
||||
local o = game.overworld
|
||||
local id = o and o.map and o.map.id
|
||||
if id ~= last then
|
||||
last = id
|
||||
seg(prefix .. ":" .. tostring(id))
|
||||
print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id),
|
||||
U.frame()))
|
||||
end
|
||||
-- press and RELEASE each frame, the way tests/voxel_perf_probe's seam
|
||||
-- crossing does: a direction left held accumulates in pressQueue and
|
||||
-- the walk stalls where a single tap would have stepped
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
game.input.state[dir] = true
|
||||
coroutine.yield()
|
||||
game.input.state[dir] = false
|
||||
end
|
||||
local o = game.overworld
|
||||
print(("[bench] %s ended on %s at cell (%d,%d)"):format(
|
||||
prefix, tostring(o and o.map and o.map.id),
|
||||
o and o.player and o.player.cellX or -1,
|
||||
o and o.player and o.player.cellY or -1))
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
|
||||
print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes))
|
||||
|
||||
-- ORDER MATTERS. The cold walk has to be the first time this session
|
||||
-- draws Route 1 and Viridian, so everything before it stays in Pallet
|
||||
-- Town -- a map whose caches the walk does not depend on. Measuring a
|
||||
-- "first entry" into a map an earlier segment already warmed is the one
|
||||
-- way to make this whole benchmark lie.
|
||||
|
||||
-- 1. the flat reference: the game with this mod present but not
|
||||
-- drawing. Every later number is only interesting against this one.
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle(90)
|
||||
seg("flat")
|
||||
U.wait(180)
|
||||
|
||||
-- 2. loading in: FULL is what a player picks first and it is the most
|
||||
-- expensive configuration there is (tilt-shift to maximum, 3D
|
||||
-- battles on). Measured from the frame the level changes, so it
|
||||
-- carries the shader compiles, the canvas allocations, the first
|
||||
-- mesh, the first atlas bake and the first glass scan together.
|
||||
settle(60)
|
||||
seg("engage.full")
|
||||
Pipelines.setLevel("voxel", 1) -- FULL
|
||||
U.wait(240)
|
||||
Perf.setSegment(nil)
|
||||
|
||||
-- 3. the low camera. 75 degrees puts the horizon in frame; the rungs
|
||||
-- below it are the control. Standing still, so what is measured is
|
||||
-- the frame's own cost and not the walk's mesh streaming.
|
||||
for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
settle(60) -- let the tween finish
|
||||
seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1]))
|
||||
U.wait(180)
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
|
||||
-- 3b. the same low camera at DUSK, which is where the sky costs most:
|
||||
-- the horizon is in frame, so the banded region is at its tallest,
|
||||
-- and the sun is low enough to be in it -- the disc only draws at
|
||||
-- all when it is above the horizon point, so a midday run never
|
||||
-- touches that code and would report it as free.
|
||||
for _, when in ipairs({ "dusk", "night" }) do
|
||||
DayNight.setting:sync(when)
|
||||
settle(60)
|
||||
seg("pitch:75:" .. when)
|
||||
U.wait(180)
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
DayNight.setting:sync("day")
|
||||
settle(30)
|
||||
|
||||
-- 4/5. arriving somewhere new, at the rung that hurts.
|
||||
--
|
||||
-- Arrival is measured by LOADING each map rather than by walking into
|
||||
-- it. Walking would be more lifelike, but Route 1's ledges make a held
|
||||
-- direction stall against geometry, so a fixed frame count buys a
|
||||
-- different amount of travel on every run -- and a benchmark whose
|
||||
-- route drifts cannot compare two runs at all. A load is the same
|
||||
-- arrival stripped of the travel: the map swaps, and the next frames
|
||||
-- pay for its mesh, its structure analysis, its atlas bake and its
|
||||
-- glass mask exactly as they do behind a door fade.
|
||||
--
|
||||
-- Then the identical list a second time. Every cost in the gap between
|
||||
-- the two passes is a cache that was cold, and that gap IS the
|
||||
-- complaint.
|
||||
Pipelines.setLevel("voxel", 5) -- 75 degrees
|
||||
local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22",
|
||||
"VIRIDIAN_FOREST", "PEWTER_CITY" }
|
||||
local DWELL = math.max(60, math.floor(HOLD / #TOUR))
|
||||
|
||||
-- Stand in the middle of each map, derived from its own def rather than
|
||||
-- written down: a hardcoded cell that falls outside a map teleports the
|
||||
-- player nowhere and the segment silently measures the previous map.
|
||||
local function centreOf(id)
|
||||
local def = game.data.maps and game.data.maps[id]
|
||||
if not def then return nil end
|
||||
return math.floor(def.width), math.floor(def.height)
|
||||
end
|
||||
|
||||
local function tour(prefix)
|
||||
for _, id in ipairs(TOUR) do
|
||||
local cx, cy = centreOf(id)
|
||||
if cx then
|
||||
U.teleport(game, id, cx, cy, "up")
|
||||
-- the segment opens on the frame AFTER the teleport, so the load
|
||||
-- itself is not charged to the arrival it caused
|
||||
seg(prefix .. ":" .. id)
|
||||
U.wait(DWELL)
|
||||
Perf.setSegment(nil)
|
||||
else
|
||||
print("[bench] skipping unknown map " .. id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
tour("first")
|
||||
tour("revisit")
|
||||
|
||||
-- 6. streaming while walking: the one crossing that is reliably
|
||||
-- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1
|
||||
-- south into Pallet, which pulls a neighbour's meshes in mid-stride.
|
||||
U.teleport(game, "ROUTE_1", 10, 34, "down")
|
||||
settle(120)
|
||||
walk("down", 240, "walk")
|
||||
|
||||
-- ---- the report ---------------------------------------------------
|
||||
|
||||
Perf.setSegment(nil)
|
||||
Perf.printReport("bench " .. TAG)
|
||||
Perf.write(TAG, {
|
||||
tag = TAG,
|
||||
loadBytes = loadBytes,
|
||||
hold = HOLD,
|
||||
texturememory = Perf.texturememory or 0,
|
||||
canvases = Perf.canvases or 0,
|
||||
images = Perf.images or 0,
|
||||
})
|
||||
print("[bench] done")
|
||||
end
|
||||
@@ -0,0 +1,411 @@
|
||||
-- Figures: a person drawn INTO furniture, cut out by an authored pixel
|
||||
-- mask and stood up on top of it (TileShape.figures / Structures.
|
||||
-- buildFigures). The Pokemon Center's seated man is the case the feature
|
||||
-- exists for, so this drives the real profile entry over a hand-built
|
||||
-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08,
|
||||
-- as every Center places it:
|
||||
--
|
||||
-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN,
|
||||
-- y=9 52 53 60 27 57/60 floor he overhangs east onto,
|
||||
-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base
|
||||
-- y=11 42 43 26 27
|
||||
--
|
||||
-- No love, no GPU and no pixel access: a figure is authored rather than
|
||||
-- detected, which is exactly what lets it build headless.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader)
|
||||
|
||||
local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
|
||||
local V = { path = ROOT }
|
||||
|
||||
local function chunkFor(rel)
|
||||
local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing")
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return assert(load(src, "@" .. ROOT .. "/" .. rel))
|
||||
end
|
||||
|
||||
local modules, dataFiles = {}, {}
|
||||
function V.require(name)
|
||||
if modules[name] == nil then
|
||||
modules[name] = chunkFor("lib/" .. name .. ".lua")(V)
|
||||
end
|
||||
return modules[name]
|
||||
end
|
||||
function V.data(name)
|
||||
if dataFiles[name] == nil then
|
||||
dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V)
|
||||
end
|
||||
return dataFiles[name]
|
||||
end
|
||||
|
||||
local TileShape = V.require("TileShape")
|
||||
local Structures = V.require("Structures")
|
||||
|
||||
-- ------- the authored mask parses
|
||||
|
||||
local figs = TileShape.figures("POKECENTER")
|
||||
T.check(type(figs) == "table" and #figs == 1,
|
||||
"POKECENTER carries exactly one figure")
|
||||
|
||||
local fig = figs[1]
|
||||
T.eq(fig.w, 3, "the figure is three tiles across")
|
||||
T.eq(fig.h, 2, "the figure is two tiles tall")
|
||||
T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans")
|
||||
T.check(fig.class == nil,
|
||||
"a figure carries no class -- it is always a flat sprite card")
|
||||
|
||||
local W = fig.w * 8
|
||||
local function on(lx, ly) return fig.mask[ly * W + lx] == true end
|
||||
|
||||
-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the
|
||||
-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7
|
||||
-- only, rows 3-7 are his hair in both
|
||||
T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1),
|
||||
"the arm's own top rows stay with the couch")
|
||||
T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2")
|
||||
for ly = 3, 7 do
|
||||
T.check(on(6, ly) and on(7, ly),
|
||||
"the back of his head fills both columns at row " .. ly)
|
||||
end
|
||||
|
||||
-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND
|
||||
-- the right side of his face, and masking it out by shade slit his cheek
|
||||
for ly = 5, 8 do
|
||||
T.check(on(15, ly),
|
||||
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
|
||||
end
|
||||
|
||||
T.eq(fig.tiles[1], 36, "it starts at the couch's west arm")
|
||||
T.eq(fig.tiles[2], 37, "then the tile his head is drawn in")
|
||||
T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off")
|
||||
T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion")
|
||||
T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)")
|
||||
T.eq(fig.under[6], 26, "and (60 -> 26)")
|
||||
|
||||
-- the mask is one connected figure: an overhang that floats free of him
|
||||
-- is the failure this feature exists to avoid
|
||||
local seen, first = {}, nil
|
||||
for i in pairs(fig.mask) do first = first or i end
|
||||
local stack, reached = { first }, 0
|
||||
seen[first] = true
|
||||
while #stack > 0 do
|
||||
local i = table.remove(stack)
|
||||
reached = reached + 1
|
||||
local x, y = i % W, math.floor(i / W)
|
||||
for dy = -1, 1 do
|
||||
for dx = -1, 1 do
|
||||
local nx, ny = x + dx, y + dy
|
||||
local ni = ny * W + nx
|
||||
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W
|
||||
and fig.mask[ni] and not seen[ni] then
|
||||
seen[ni] = true
|
||||
stack[#stack + 1] = ni
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
T.eq(reached, fig.n, "the mask is a single connected figure")
|
||||
|
||||
-- ------- the couch block, as the Centers place it
|
||||
|
||||
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
|
||||
|
||||
local COUNTER = { class = "counter", h = 8, art = "upright",
|
||||
flat = false, authored = true }
|
||||
local GROUND = { class = "ground", h = 0, art = "flat",
|
||||
flat = true, authored = false }
|
||||
|
||||
local BLOCK = { 36, 37, 57, 11,
|
||||
52, 53, 60, 27,
|
||||
38, 39, 54, 11,
|
||||
42, 43, 26, 27 }
|
||||
local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true,
|
||||
[42] = true, [43] = true, [52] = true, [53] = true }
|
||||
|
||||
local function scene()
|
||||
local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
|
||||
ground = {}, runs = {} }
|
||||
for i = 1, 16 do
|
||||
local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4)
|
||||
local tile = BLOCK[i]
|
||||
S.tileAt[keyOf(tx, ty)] = tile
|
||||
S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND
|
||||
end
|
||||
return S
|
||||
end
|
||||
|
||||
-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn
|
||||
-- sitting on it), the floor cell east of it is walkable
|
||||
local map = {
|
||||
tileset = { id = "POKECENTER", tilesPerRow = 16,
|
||||
imageWidth = 128, imageHeight = 48 },
|
||||
isWalkableCell = function(_, cx) return cx >= 1 end,
|
||||
}
|
||||
|
||||
-- ------- he comes off the couch
|
||||
|
||||
local S = scene()
|
||||
Structures.buildFigures(S, map, 0, 3, 8, 11)
|
||||
|
||||
T.eq(#S.figures, 1, "one figure card was built")
|
||||
local card = S.figures[1]
|
||||
T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else")
|
||||
|
||||
T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now")
|
||||
T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion")
|
||||
T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too")
|
||||
T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again")
|
||||
T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it")
|
||||
T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched")
|
||||
T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base")
|
||||
|
||||
-- the couch keeps its box: a figure changes ART, never class
|
||||
T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
|
||||
"his tiles are still the couch's half-cell box")
|
||||
T.check(S.skip[keyOf(1, 8)] ~= true,
|
||||
"and are not skipped -- the couch still renders there")
|
||||
|
||||
-- ------- the card is flat, and stands at its feet
|
||||
|
||||
local minX, maxX, minY, maxY, minZ, maxZ
|
||||
for _, q in ipairs(card.quads) do
|
||||
for c = 1, 4 do
|
||||
local p = q[c]
|
||||
minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1])
|
||||
minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2])
|
||||
minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3])
|
||||
end
|
||||
end
|
||||
|
||||
T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)")
|
||||
T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab")
|
||||
T.eq(minY, 0, "local space: his feet are the card's origin")
|
||||
T.eq(maxY, 16, "and he is his drawn 16px tall")
|
||||
T.eq(minX, 0, "his west edge is the card's origin too")
|
||||
T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang")
|
||||
|
||||
-- ------- and where VoxelScene stands it
|
||||
|
||||
T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor")
|
||||
T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6")
|
||||
T.eq(card.wz, 76,
|
||||
"and pivoting at the middle of the tile row his feet are drawn in")
|
||||
|
||||
-- ------- a map that does not draw him builds nothing
|
||||
|
||||
local other = scene()
|
||||
other.tileAt[keyOf(1, 8)] = 40
|
||||
Structures.buildFigures(other, map, 0, 3, 8, 11)
|
||||
T.eq(#other.figures, 0, "no match, no figure")
|
||||
T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted")
|
||||
|
||||
-- ------- and it never fires twice on the same drawing
|
||||
|
||||
local twice = scene()
|
||||
Structures.buildFigures(twice, map, 0, 3, 8, 11)
|
||||
Structures.buildFigures(twice, map, 0, 3, 8, 11)
|
||||
T.eq(#twice.figures, 1,
|
||||
"the repaint replaces the pattern, so a rescan cannot match it again")
|
||||
|
||||
-- ------- a figure with a DEPTH is an object, not a card
|
||||
--
|
||||
-- The Marts' cash register: the same authored-mask escape, but a machine
|
||||
-- set down on a counter is a box seen from the front rather than a
|
||||
-- face-on icon, so it builds as a per-pixel solid. Driven over a
|
||||
-- synthetic copy of the counter's east arm, as all nine maps on the MART
|
||||
-- id draw it at cell (1,5):
|
||||
--
|
||||
-- y=9 16 41 the work surface north of it
|
||||
-- y=10 14 15 the register: keypad and receipt curl
|
||||
-- y=11 30 31
|
||||
-- y=12 16 41 the work surface it stands on
|
||||
|
||||
T.check(TileShape.figures("POKECENTER")[1].depth == nil,
|
||||
"the seated man states no depth -- he stays a flat sprite card")
|
||||
|
||||
local regs = TileShape.figures("MART")
|
||||
T.check(type(regs) == "table" and #regs == 1,
|
||||
"MART carries exactly one figure")
|
||||
local reg = regs[1]
|
||||
T.eq(reg.w, 2, "the register is two tiles across")
|
||||
T.eq(reg.h, 2, "and two tall")
|
||||
T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans")
|
||||
T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell")
|
||||
T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2,
|
||||
"the four rows above its drawn top edge are 2-voxel paper")
|
||||
T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8
|
||||
and reg.flat.r0 == 4 and reg.flat.r1 == 11,
|
||||
"and the keypad is a TOP-VIEW rect, not a face")
|
||||
|
||||
local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 },
|
||||
[11] = { 30, 31 }, [12] = { 16, 41 } }
|
||||
local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
|
||||
ground = {}, runs = {}, objectQuads = {} }
|
||||
for ty, row in pairs(MART_ROWS) do
|
||||
for i, tile in ipairs(row) do
|
||||
martS.tileAt[keyOf(1 + i, ty)] = tile
|
||||
martS.shapeAt[keyOf(1 + i, ty)] = COUNTER
|
||||
end
|
||||
end
|
||||
local martMap = {
|
||||
tileset = { id = "MART", tilesPerRow = 16,
|
||||
imageWidth = 128, imageHeight = 48 },
|
||||
isWalkableCell = function() return false end,
|
||||
}
|
||||
Structures.buildFigures(martS, martMap, 2, 3, 9, 12)
|
||||
|
||||
T.eq(#martS.figures, 0, "no card was built -- it is a solid")
|
||||
T.eq(#martS.objectQuads, 351,
|
||||
"and it landed in the standee channel as 351 quads")
|
||||
T.eq(martS.tileAt[keyOf(2, 10)], 16,
|
||||
"its tiles wear the plain work surface now")
|
||||
T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them")
|
||||
T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter",
|
||||
"and keep the counter box the machine stands on")
|
||||
|
||||
local rx0, rx1, ry0, ry1, rz0, rz1
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do
|
||||
local p = q[c]
|
||||
rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1])
|
||||
ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2])
|
||||
rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3])
|
||||
end
|
||||
end
|
||||
T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor")
|
||||
T.eq(ry1, 24, "and is its drawn 16px tall")
|
||||
T.eq(rx0, 18, "west edge at the mask's column 2")
|
||||
T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)")
|
||||
T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn")
|
||||
T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back")
|
||||
|
||||
-- the two thicknesses: the body at 8, the receipt curl at 2, the curl
|
||||
-- centred in the body's own band rather than flush with its front
|
||||
local bands = {}
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do bands[q[c][3]] = true end
|
||||
end
|
||||
for _, z in ipairs({ 84, 89, 91, 96 }) do
|
||||
T.check(bands[z], "the model has a face at z = " .. z)
|
||||
end
|
||||
local curl = {}
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2])
|
||||
if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end
|
||||
end
|
||||
T.check(curl[89] and curl[91] and not curl[84] and not curl[96],
|
||||
"clear of the arm's top only the 2-voxel paper band exists")
|
||||
|
||||
-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and
|
||||
-- the keypad lies on it as a horizontal plate, so the whole machine is
|
||||
-- exactly three surfaces: a foot, an arm, and a deck in the notch.
|
||||
local plate, deckTop = {}, 0
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2]
|
||||
and q[3][2] == q[4][2]
|
||||
if flatQuad and q[1][2] == 13 then
|
||||
plate[#plate + 1] = q
|
||||
elseif flatQuad and q[1][2] == 12 then
|
||||
deckTop = deckTop + 1
|
||||
end
|
||||
end
|
||||
T.eq(#plate, 83,
|
||||
"the keypad lies FLAT: one top quad per masked voxel of the deck")
|
||||
T.eq(deckTop, 7,
|
||||
"on the base band's own top, which is 4 voxels up (drawn rows 12-15)")
|
||||
local dz0, dz1
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
if q[1][2] == 12 and q[3][2] == 12 then
|
||||
for c = 1, 4 do
|
||||
dz0 = math.min(dz0 or q[c][3], q[c][3])
|
||||
dz1 = math.max(dz1 or q[c][3], q[c][3])
|
||||
end
|
||||
end
|
||||
end
|
||||
T.eq(dz0, 84, "and that deck runs the body's whole depth")
|
||||
T.eq(dz1, 96, "-- plain behind the panel, covered by it in front")
|
||||
|
||||
local px0, px1, pz0, pz1
|
||||
for _, q in ipairs(plate) do
|
||||
for c = 1, 4 do
|
||||
px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1])
|
||||
pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3])
|
||||
end
|
||||
end
|
||||
T.eq(px0, 18, "the deck spans the mask's columns 2..8")
|
||||
T.eq(px1, 25, "-- the keypad panel and its own black rim")
|
||||
T.eq(pz1, 96, "the deck reaches the body's front edge")
|
||||
T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels")
|
||||
|
||||
-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12
|
||||
-- voxels of deck doubles every second one and blurs nothing
|
||||
local perRow16, atlasH16 = 16, 48
|
||||
local depthRow = {}
|
||||
for _, q in ipairs(plate) do
|
||||
local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
|
||||
depthRow[z] = math.floor(q.v * atlasH16)
|
||||
end
|
||||
local seen = {}
|
||||
for z = 84, 95 do
|
||||
T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel")
|
||||
seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1
|
||||
end
|
||||
T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim")
|
||||
T.eq(depthRow[84], 4, "the back one wears its top rim")
|
||||
local doubled = 0
|
||||
for _, n in pairs(seen) do
|
||||
T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels")
|
||||
if n == 2 then doubled = doubled + 1 end
|
||||
end
|
||||
T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12")
|
||||
|
||||
|
||||
-- and the arm still stands its drawn 8 rows above that deck, carrying
|
||||
-- the paper: nothing in the notch reaches higher than the plate
|
||||
local armTop, notchTop = 0, 0
|
||||
for _, q in ipairs(martS.objectQuads) do
|
||||
for c = 1, 4 do
|
||||
if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2])
|
||||
elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end
|
||||
end
|
||||
end
|
||||
T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px")
|
||||
T.eq(notchTop, 23,
|
||||
"and west of it only the keys (13) and the paper overhanging them")
|
||||
|
||||
-- ------- prop_bg: the shades a pinned prop treats as background
|
||||
--
|
||||
-- The potted plants needed this: their pot's olive base is drawn flush on
|
||||
-- the bottom of the plant block, so the ordinary vote (shades touching the
|
||||
-- drawing's own bounding box) read "dark" as background and drained every
|
||||
-- olive pixel in the plant.
|
||||
|
||||
local bgRules = TileShape.propBg("POKECENTER")
|
||||
T.check(type(bgRules) == "table", "POKECENTER names prop background shades")
|
||||
for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do
|
||||
local set = bgRules and bgRules[tile]
|
||||
T.check(type(set) == "table" and set.light and set.white
|
||||
and not set.dark and not set.black,
|
||||
"plant tile " .. tile .. ": light/white are background, dark is the pot")
|
||||
end
|
||||
|
||||
-- and it is scoped: the healing consoles' screens and the PC keep the
|
||||
-- ordinary vote, because they want the opposite call on those same shades
|
||||
for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do
|
||||
T.check(bgRules[tile] == nil,
|
||||
"tile " .. tile .. " keeps the ordinary background vote")
|
||||
end
|
||||
|
||||
-- a tileset with no prop_bg at all answers nil rather than an empty table,
|
||||
-- so Structures can skip the lookup entirely
|
||||
T.check(TileShape.propBg("CAVERN") == nil,
|
||||
"a tileset that names none answers nil")
|
||||
|
||||
T.finish("DRAMATIC_SHAPE figures")
|
||||
@@ -0,0 +1,346 @@
|
||||
-- Driver: the pixel-identity gate for performance work.
|
||||
--
|
||||
-- Every optimization in this mod's performance pass claims the frame comes
|
||||
-- out the same. This driver is what makes that claim checkable rather than
|
||||
-- asserted: it renders a fixed set of scenes -- several maps, indoors and
|
||||
-- out, at every camera rung, in every display mode -- and writes one PNG
|
||||
-- per scene. Run it before a change and after it, hash the two directories,
|
||||
-- and any file whose hash moved is a scene the change altered.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \
|
||||
-- SHOT_DIR=<dir> AB_TAG=before lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- SHOT_DIR output directory (created if missing) (default "shots/ab")
|
||||
-- AB_TAG subdirectory under SHOT_DIR (default "before")
|
||||
-- AB_MODES display modes to sweep, comma list (default all four)
|
||||
--
|
||||
-- DETERMINISM is the whole game here, because a shot that differs for a
|
||||
-- reason other than the change under test makes the gate useless:
|
||||
--
|
||||
-- * the day/night clock is PINNED (an unpinned sky is a different sky
|
||||
-- every second, and it drives the sun angle and the shadow frustum);
|
||||
-- * the animated tile slots ride the engine's 60Hz counter, so every
|
||||
-- scene is reached after the SAME number of frames from the same
|
||||
-- starting state, and the water is at the same point in its roll;
|
||||
-- * levels are set through Pipelines.setLevel, never the hotkey, so the
|
||||
-- run cannot write the player's options;
|
||||
-- * encounters are stubbed off -- a wild battle would replace the scene
|
||||
-- the shot is named for.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/ab")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
-- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is
|
||||
-- seeded differently every launch -- so two runs of this driver put the
|
||||
-- same townsfolk in different places and every shot with a person in it
|
||||
-- differs for a reason that has nothing to do with the change under
|
||||
-- test. Freeze them: `frozen` is the flag the NPC's own update already
|
||||
-- honours, and an NPC mid-step still finishes it, so the settle below
|
||||
-- lands on a still scene. They are still POSED and still drawn, so the
|
||||
-- billboard, its lean and its shadow are all still under test.
|
||||
local NPC = require("src.world.NPC")
|
||||
if not NPC.dramaticShapeAbFreeze then
|
||||
local inner = NPC.update
|
||||
function NPC:update(...)
|
||||
self.frozen = true
|
||||
return inner(self, ...)
|
||||
end
|
||||
NPC.dramaticShapeAbFreeze = true
|
||||
end
|
||||
pcall(love.math.setRandomSeed, 20260730)
|
||||
|
||||
-- Freeze the tile-animation clock, on BOTH routes to it.
|
||||
--
|
||||
-- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the
|
||||
-- same speed on a 60Hz and a 144Hz panel), which means the step a shot
|
||||
-- catches depends on how fast the machine got there rather than on
|
||||
-- anything the run did. Stubbing tick pins the counter the flat tile
|
||||
-- layer reads.
|
||||
--
|
||||
-- The mod reads the SAME counter but through its own chain
|
||||
-- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports
|
||||
-- one, else the local off tick's upvalues, else -- and this is the trap
|
||||
-- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it
|
||||
-- ALONE knocks the mod onto the wall-clock fallback and makes the
|
||||
-- flowers drift between two otherwise identical runs. Exporting a
|
||||
-- constant animFrame takes the first branch and pins that route too.
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
|
||||
-- The scenes. Chosen for what each one can BREAK, not for looks:
|
||||
-- ROUTE_1 open ground, grass billboards, a long view north --
|
||||
-- the case a shadow-frustum or culling change moves
|
||||
-- VIRIDIAN_CITY buildings, window panes (the glass mask), signs
|
||||
-- PALLET_TOWN the seam with Route 1: neighbour meshes and ring
|
||||
-- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion
|
||||
-- REDS_HOUSE_1F indoors: no sky, no sun, authored figures
|
||||
-- PEWTER_CITY a second tileset with its own atlas bake
|
||||
local SCENES = {
|
||||
{ id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" },
|
||||
{ id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" },
|
||||
{ id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" },
|
||||
{ id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" },
|
||||
{ id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" },
|
||||
{ id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" },
|
||||
}
|
||||
|
||||
-- OFF is in the list deliberately: a change that speeds the 3D path up
|
||||
-- must not have touched the flat one either. Then the three camera
|
||||
-- rungs, 75 last because it is the low camera this performance work is
|
||||
-- aimed at.
|
||||
--
|
||||
-- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings
|
||||
-- PRESET, not a render path: it sets tilt-shift to maximum, flattens the
|
||||
-- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME
|
||||
-- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync),
|
||||
-- which overrides this driver's pinned clock and makes every shot after
|
||||
-- it depend on the wall clock. It also persists all of that, so one run's
|
||||
-- FULL changes the options the NEXT run starts from. What FULL renders is
|
||||
-- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers
|
||||
-- exactly.
|
||||
local RUNGS = {}
|
||||
for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do
|
||||
RUNGS[#RUNGS + 1] = tonumber(n)
|
||||
end
|
||||
|
||||
local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0)
|
||||
|
||||
-- AB_SHADOW=0 renders with the sun pass's contribution turned off
|
||||
-- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A
|
||||
-- bisection tool: when a set of shots will not reproduce, this says
|
||||
-- whether what is moving is in the shadow map or somewhere else.
|
||||
if os.getenv("AB_SHADOW") == "0" then
|
||||
V.require("Voxel3D").SHADOW_ALPHA = 0
|
||||
end
|
||||
|
||||
-- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic`
|
||||
-- are the SGB paths this mod bakes an atlas for, `gbc` is the shared
|
||||
-- default, and `redpp` is the one that rebakes an atlas PER MAP -- four
|
||||
-- genuinely different routes through TerrainAtlas.
|
||||
local MODES = {}
|
||||
for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do
|
||||
MODES[#MODES + 1] = m
|
||||
end
|
||||
|
||||
-- Two times of day, because half the shader only runs in one of them:
|
||||
-- the window lamps, the moon disc and the night tint are all dark-only,
|
||||
-- and the glint sweep and the sun disc are day-only.
|
||||
local TIMES = { "day", "night" }
|
||||
|
||||
local shots, missed = 0, 0
|
||||
|
||||
-- U.shot's own mkdir is the POSIX one, which cmd.exe does not
|
||||
-- understand, and a missing directory makes every capture vanish
|
||||
-- silently. Try both spellings once, up front.
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
-- A capture that always costs the SAME number of frames.
|
||||
--
|
||||
-- U.shot spins up to 120 frames waiting for the capture to land, which
|
||||
-- is right for a screenshot and wrong for this: the animated tile slots
|
||||
-- (water rolling, flowers opening) ride the engine's frames-since-boot
|
||||
-- counter, so a scene reached after a different number of frames renders
|
||||
-- its water at a different point in the roll and the shot differs for a
|
||||
-- reason no change caused. A driver resume and a rendered frame are 1:1
|
||||
-- here, so the capture lands on the next draw and a fixed budget is both
|
||||
-- enough and constant.
|
||||
local CAPTURE_FRAMES = 4
|
||||
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
-- Wait for the scene to actually BE the scene the shot is named for.
|
||||
-- Two things are still in motion after a teleport, and both are timed in
|
||||
-- wall-clock seconds rather than frames, so "wait N frames" settles them
|
||||
-- by a different amount on every machine and every run:
|
||||
--
|
||||
-- the build queue -- meshes are built on a per-frame time budget, so a
|
||||
-- slower run captures a half-built neighbour;
|
||||
-- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot
|
||||
-- taken before it lands is at some arbitrary intermediate pitch.
|
||||
--
|
||||
-- Both are waited on by their own completion flag, then a short fixed
|
||||
-- settle. The variable wait is harmless now that the animation clock is
|
||||
-- frozen above -- otherwise it would move the water instead.
|
||||
-- and the CAMERA, which is the subtle one. It eases toward the player
|
||||
-- over wall-clock dt, so after a fixed wait it has covered a distance
|
||||
-- that depends on how fast the machine ran -- and the sun pass is only
|
||||
-- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's
|
||||
-- shadow signature), so a frame caught mid-ease carries a shadow map
|
||||
-- fitted for a slightly different camera than the one it is drawn with.
|
||||
-- That is a real and deliberate tolerance in the mod, but it makes the
|
||||
-- gate compare two arbitrary points inside it. Waiting for the camera
|
||||
-- to stop moving entirely puts every shot at the same steady state.
|
||||
local function cameraStill()
|
||||
local o = game.overworld
|
||||
local c = o and o.camera
|
||||
if not c then return true end
|
||||
local lx, ly, held = nil, nil, 0
|
||||
for _ = 1, 300 do
|
||||
if c.x == lx and c.y == ly then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
lx, ly = c.x, c.y
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function settleBuild()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
cameraStill()
|
||||
-- and then force one final sun pass at the settled camera. The map is
|
||||
-- only redrawn when the camera crosses a quarter world pixel, so a
|
||||
-- still camera holds whatever was drawn at the moment it last did --
|
||||
-- correct to within that tolerance, but fitted from a position that
|
||||
-- depends on where the easing happened to be, which differs by a few
|
||||
-- hundredths of a pixel between runs and moves every shadow edge by a
|
||||
-- shade or two. Forgetting the stamp redraws from the state the shot
|
||||
-- is actually taken in, and two runs then agree exactly.
|
||||
-- guarded so this driver can also be pointed at a build that predates
|
||||
-- the seam, which is exactly what capturing a "before" reference means
|
||||
if ShadowMap.forget then ShadowMap.forget() end
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- AB_TRACE=1 prints the state each shot was taken in. When two runs of
|
||||
-- this driver disagree, this is what says which input moved.
|
||||
local TRACE = os.getenv("AB_TRACE") == "1"
|
||||
|
||||
local function trace(name)
|
||||
if not TRACE then return end
|
||||
local o = game.overworld
|
||||
local e = ShadowMap.extent or {}
|
||||
print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d")
|
||||
:format(name,
|
||||
o and o.camera and o.camera.x or -1,
|
||||
o and o.camera and o.camera.y or -1,
|
||||
o and o.player and o.player.px or -1,
|
||||
o and o.player and o.player.py or -1,
|
||||
ShadowMap.res or 0,
|
||||
e[1] or 0, e[2] or 0, e[3] or 0,
|
||||
ShadowMap.KX or 0, ShadowMap.KZ or 0,
|
||||
Voxel.angle or 0,
|
||||
ChunkMesher.pending()))
|
||||
end
|
||||
|
||||
-- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save
|
||||
-- directory. When the scene differs but every input to the sun pass is
|
||||
-- identical, the map is the only place left to look.
|
||||
local DUMP = os.getenv("AB_SHADOWDUMP") == "1"
|
||||
|
||||
local function dumpShadow(name)
|
||||
if not DUMP then return end
|
||||
local tex = ShadowMap.texture()
|
||||
if not (tex and tex.newImageData) then return end
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("ab_shadow")
|
||||
tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png")
|
||||
end)
|
||||
end
|
||||
|
||||
local function capture(name)
|
||||
trace(name)
|
||||
dumpShadow(name)
|
||||
local path = ("%s/%s.png"):format(ROOT, name)
|
||||
game.capturePath = path
|
||||
U.wait(CAPTURE_FRAMES)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
else
|
||||
missed = missed + 1
|
||||
print("[ab] capture did not reach disk: " .. path)
|
||||
end
|
||||
end
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local function setMode(mode)
|
||||
local known = false
|
||||
for _, m in ipairs(PaletteFX.MODES) do
|
||||
if m == mode then known = true break end
|
||||
end
|
||||
if not known then return false end
|
||||
return (pcall(PaletteFX.setMode, mode))
|
||||
end
|
||||
|
||||
-- A fixed zoom, so the view size every shot is composed at is the same
|
||||
-- one. Zoom is persisted, so without this a session that ever ran the
|
||||
-- FULL preset (which fits the zoom to the window) leaves a different
|
||||
-- view size behind for every later run.
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
for _, mode in ipairs(MODES) do
|
||||
if setMode(mode) then
|
||||
for _, when in ipairs(TIMES) do
|
||||
DayNight.setting:sync(when)
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs(RUNGS) do
|
||||
U.teleport(game, s.id, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
-- pinned AFTER the voxel rung, because FULL is a preset that
|
||||
-- reaches over and sets this row itself (main.lua's applyFull)
|
||||
-- and persists it -- so without this, one run's FULL leaks a
|
||||
-- blur level into the NEXT run's options.lua and every shot
|
||||
-- differs for a reason no code change caused. Sharp by
|
||||
-- default: a gaussian smears a one-pixel geometry difference
|
||||
-- across the whole frame, which is exactly what a gate meant
|
||||
-- to localise differences must not do. AB_TSHIFT=3 runs the
|
||||
-- blur path deliberately.
|
||||
Pipelines.setLevel("tiltshift", TSHIFT)
|
||||
-- Wait for the build queue to drain rather than for a fixed
|
||||
-- number of frames. Meshes are built on a per-frame time
|
||||
-- budget, so how much of a map exists after N frames is a
|
||||
-- property of the MACHINE -- a slower run captures a
|
||||
-- half-built neighbour and the shot differs for no reason the
|
||||
-- change caused. Draining first, then settling a fixed 40
|
||||
-- frames for the camera tween, makes the scene the same
|
||||
-- scene everywhere. (Safe now that the animation clock above
|
||||
-- is frozen: a variable wait no longer moves the water.)
|
||||
settleBuild()
|
||||
capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung))
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
print("[ab] display mode " .. mode .. " unavailable, skipped")
|
||||
end
|
||||
end
|
||||
|
||||
print(("[ab] %d shots into %s (%d failed to reach disk)")
|
||||
:format(shots, ROOT, missed))
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the
|
||||
-- world bend does to the reflective pass.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "after")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[water] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local Water = V.require("Water")
|
||||
-- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to
|
||||
-- tell an artefact of the one from an artefact of the other
|
||||
if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync(os.getenv("WATER_TIME") or "night")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- Stand on the walkable cell just north of the widest run of water on the
|
||||
-- map, so a scene is picked by where the water actually is rather than by
|
||||
-- a coordinate guessed off the block list.
|
||||
local TileShape = V.require("TileShape")
|
||||
local function shore(map)
|
||||
local def = map.def
|
||||
local shapes = TileShape.forMap(map)
|
||||
local function classAt(cx, cy)
|
||||
local tx, ty = cx * 2, cy * 2
|
||||
local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty)
|
||||
return s and s.class
|
||||
end
|
||||
local best, bestRun = nil, 0
|
||||
for cy = 1, def.height * 2 - 1 do
|
||||
local run, start = 0, nil
|
||||
for cx = 0, def.width * 2 - 1 do
|
||||
if classAt(cx, cy) == "water" then
|
||||
start = start or cx
|
||||
run = run + 1
|
||||
if run > bestRun and classAt(cx, cy - 1) ~= "water" then
|
||||
bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 }
|
||||
end
|
||||
else
|
||||
run, start = 0, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "PALLET_TOWN", label = "pallet" },
|
||||
{ map = "CERULEAN_CITY", label = "cerulean" },
|
||||
{ map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" },
|
||||
{ map = "ROUTE_24", label = "route24" },
|
||||
{ map = "VIRIDIAN_CITY", label = "viridian" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
if not s.x then
|
||||
-- teleport once so the map is loaded, then let the scan place us
|
||||
U.teleport(game, s.map, 1, 1, "down")
|
||||
U.wait(4)
|
||||
local spot = game.overworld and game.overworld.map and shore(game.overworld.map)
|
||||
if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end
|
||||
print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y))
|
||||
end
|
||||
for _, rung in ipairs({ 3, 4 }) do
|
||||
for _, curve in ipairs({ 0, 3 }) do
|
||||
U.teleport(game, s.map, s.x, s.y, s.face or "down")
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
WorldCurve.setting:setIndex(curve + 1, game)
|
||||
settle()
|
||||
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then f:close() shots = shots + 1
|
||||
else print("[water] capture missed: " .. path) end
|
||||
end
|
||||
end
|
||||
end
|
||||
print(("[water] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -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
|
||||
+1217
-13
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user