Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9700806a92 | |||
| f5970d8d9a | |||
| e3c13edda7 | |||
| 6e9b787e41 | |||
| 399a10a124 | |||
| c0c180fd01 | |||
| 2ce44586c2 | |||
| e8caa4f537 | |||
| a8c2d8ce5b | |||
| 395f51d268 | |||
| 1fa29a831f | |||
| 44f729e8c2 | |||
| f1063abd0f | |||
| b0d37cd8e6 | |||
| 3f7210bfcd | |||
| 9ef8644bff | |||
| e6d4059c38 | |||
| 1a283d6771 | |||
| a7c9541ac4 | |||
| 91cc2d6f51 | |||
| c82598b24c | |||
| 47363b8d23 | |||
| 752653e243 | |||
| 6887f5d951 | |||
| a140980b1d | |||
| 3cd3fe431d | |||
| acb2eadeb4 | |||
| 1a69489305 | |||
| 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 | |||
| 917fbac38c | |||
| 0f303e7975 | |||
| d8d1d7e336 | |||
| 4e6aa30c2c | |||
| 2e0ae37bcd | |||
| 9207712b19 | |||
| 2447046aee |
@@ -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"
|
||||||
+1423
-3
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 DramaticShape
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -3,24 +3,7 @@
|
|||||||
A mod for the [Pokémon Gen 1 Recompilation
|
A mod for the [Pokémon Gen 1 Recompilation
|
||||||
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
||||||
|
|
||||||
The overworld as a 3D diorama. Terrain is extruded into real geometry,
|
The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR.
|
||||||
occlusion comes from a depth buffer rather than a y-sort, characters stand
|
|
||||||
as leaning sprite slabs, a shadow map throws real cast shadows across
|
|
||||||
whatever they land on, and an optional tilt-shift pass sells the
|
|
||||||
miniature-model look.
|
|
||||||
|
|
||||||
And battles fought on that world rather than on a white field. When
|
|
||||||
something picks a fight the map's NPCs are culled, the engine's own wipe
|
|
||||||
plays over the empty map, and the battle draws over the nearest patch of
|
|
||||||
clear ground — shot over the shoulder, the player's mon low and left and
|
|
||||||
the enemy high and right, with a slow parallax drift behind them and a
|
|
||||||
depth-of-field pass that keeps both of them sharp.
|
|
||||||
|
|
||||||
Purely presentational. Nothing here reaches collision, movement, triggers
|
|
||||||
or scripts — it changes what the world *looks* like and nothing about what
|
|
||||||
it *is*. The battle arena is where the **camera** goes, not where anybody
|
|
||||||
goes: no cell, facing, flag or warp is written, so the player is standing
|
|
||||||
exactly where the fight found them when it ends.
|
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
@@ -29,48 +12,65 @@ menu.
|
|||||||
|
|
||||||
| control | does |
|
| control | does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) |
|
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) |
|
||||||
|
| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
|
||||||
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
|
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
|
||||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
| `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 |
|
| `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 |
|
| `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
|
## VR
|
||||||
on the world whether or not the free-roam camera is pitched over.
|
|
||||||
|
|
||||||
## Where a battle is staged
|
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
|
||||||
|
through OpenXR on Windows — SteamVR, Oculus or WMR.
|
||||||
|
|
||||||
The mod looks for the nearest clearing shaped like this, where every `x` is
|
The **SMOOTH TURN** row appears under it while VR is ON (OFF by
|
||||||
a cell the player could walk onto — the two mons three cells apart down the
|
default): ON turns the right stick into a continuous turn instead of the
|
||||||
middle, with a one-cell apron so the camera looks across floor rather than
|
45° snap. The snap is the default deliberately — a software turn moves
|
||||||
into a wall:
|
the world past a head that did not move, which is the most reliable way
|
||||||
|
to make somebody ill in a headset — but it costs continuity, so the
|
||||||
|
choice is yours.
|
||||||
|
|
||||||
```
|
### VR controls
|
||||||
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
|
Suggested onto Touch, Index and WMR controllers (rebindable in the
|
||||||
search relaxes to the same three-cell gap with the apron given up:
|
runtime's own binding UI); pad, keyboard and mouse all keep working
|
||||||
|
alongside.
|
||||||
|
|
||||||
```
|
| control | does |
|
||||||
O
|
| --- | --- |
|
||||||
x
|
| left stick | move — grid-walks the diorama, free-walks 1ST |
|
||||||
x
|
| A / B (X / Y on the left hand) | A / B |
|
||||||
P
|
| either trigger | START |
|
||||||
```
|
| left stick click | step the VOXEL angle ladder (same as the "3" key) |
|
||||||
|
| right stick up / down | *diorama only* — zoom the model |
|
||||||
|
| right stick left / right | *1ST only* — snap-turn 45°, or turn smoothly with **SMOOTH TURN** on |
|
||||||
|
| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height |
|
||||||
|
| head | *1ST and battles* — look; FreeMove walks where you look |
|
||||||
|
| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen |
|
||||||
|
|
||||||
If neither will fit — and on a map with no open ground at all, or on a
|
## Licenses
|
||||||
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
|
This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
|
||||||
**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
|
It redistributes one third-party binary:
|
||||||
fight the diorama, and it is the way back from having left one on.
|
|
||||||
|
- **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader
|
||||||
|
(version 1.0.10.2, x64, unmodified), © The Khronos Group Inc.,
|
||||||
|
licensed under the **Apache License 2.0**. The full license text ships
|
||||||
|
alongside the DLL at
|
||||||
|
[`assets/vr/LICENSE-openxr_loader.txt`](assets/vr/LICENSE-openxr_loader.txt),
|
||||||
|
as the license requires; keep the two files together if you
|
||||||
|
redistribute this mod. Source:
|
||||||
|
[KhronosGroup/OpenXR-SDK](https://github.com/KhronosGroup/OpenXR-SDK).
|
||||||
|
|
||||||
|
Everything else in this mod is original to it, except that the voxel
|
||||||
|
geometry and shape profiles are derived from the tile and sprite data of
|
||||||
|
the original game, as documented by the
|
||||||
|
[pret/pokered](https://github.com/pret/pokered) disassembly. No ROM
|
||||||
|
data, artwork or audio is included; the mod reads the assets the host
|
||||||
|
game already has.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
openxr_loader.dll -- the Khronos OpenXR loader (x64, unmodified)
|
||||||
|
Version 1.0.10.2, from the "OpenXR.Loader" NuGet package published by
|
||||||
|
The Khronos Group. Source: https://github.com/KhronosGroup/OpenXR-SDK
|
||||||
|
|
||||||
|
Copyright (c) The Khronos Group Inc.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); the full
|
||||||
|
text of the License follows, as its terms require a copy to accompany
|
||||||
|
redistribution.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
Binary file not shown.
+11
-1
@@ -39,7 +39,17 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
-- ------- routes
|
-- ------- 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_2"] = { x = 1, y = 49, shape = "wide" },
|
||||||
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
||||||
["ROUTE_4"] = { x = 46, y = 7, 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
|
-- 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
|
-- texture. Feet on the model origin, so the model matrix only has to say
|
||||||
-- where the mon is standing and how big it is.
|
-- 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()
|
local function unitQuad()
|
||||||
if quad ~= nil then return quad or nil end
|
if quad ~= nil then return quad or nil end
|
||||||
local verts = {
|
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 grow then w, h = w * grow, h * grow end
|
||||||
if w <= 0 or h <= 0 then return false end
|
if w <= 0 or h <= 0 then return false end
|
||||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
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),
|
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
|
||||||
BattleBillboard.PULL)
|
BattleBillboard.PULL)
|
||||||
|
Voxel3D.seams(true)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -124,6 +124,14 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
|
|||||||
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
|
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
|
||||||
BattleCam.DOLLY_PERIOD = 37
|
BattleCam.DOLLY_PERIOD = 37
|
||||||
|
|
||||||
|
-- Hold the rig perfectly still (VR sets this while a session runs). The
|
||||||
|
-- drift exists to give a FLAT screen the depth cue the picture cannot
|
||||||
|
-- have; a headset gets real parallax from the player's own head, and a
|
||||||
|
-- picture that sways on its own inside VR reads as the world lurching --
|
||||||
|
-- on the floating panel especially, where the battle screen is watched
|
||||||
|
-- from a fixed seat.
|
||||||
|
BattleCam.still = false
|
||||||
|
|
||||||
BattleCam.t = 0
|
BattleCam.t = 0
|
||||||
|
|
||||||
function BattleCam.reset()
|
function BattleCam.reset()
|
||||||
@@ -160,12 +168,14 @@ function BattleCam.rig(arena, groundY)
|
|||||||
local R = BattleCam.rigFor(arena)
|
local R = BattleCam.rigFor(arena)
|
||||||
local mx, mz = arena.mid[1], arena.mid[2]
|
local mx, mz = arena.mid[1], arena.mid[2]
|
||||||
|
|
||||||
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
|
local yaw = BattleCam.still and 0
|
||||||
|
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
|
||||||
local c, s = math.cos(yaw), math.sin(yaw)
|
local c, s = math.cos(yaw), math.sin(yaw)
|
||||||
-- the breath scales the whole offset, height included, so the eye moves
|
-- the breath scales the whole offset, height included, so the eye moves
|
||||||
-- along its own line to the arena and the pitch of the shot never changes
|
-- along its own line to the arena and the pitch of the shot never changes
|
||||||
local k = 1 + BattleCam.PAN_DOLLY
|
local k = BattleCam.still and 1
|
||||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
or 1 + BattleCam.PAN_DOLLY
|
||||||
|
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||||
local dx = (R.side * c - R.back * s) * k
|
local dx = (R.side * c - R.back * s) * k
|
||||||
local dz = (R.side * s + R.back * c) * k
|
local dz = (R.side * s + R.back * c) * k
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
return fx, fy, math.max(1, fw), math.max(1, fh)
|
||||||
end
|
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
|
-- ------- the verdict
|
||||||
--
|
--
|
||||||
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
|
-- 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.
|
-- the tint below then commits both panels to that reading.
|
||||||
local wasDark = false
|
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
|
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||||
|
local toFrost = mapper(world)
|
||||||
local darkest = nil
|
local darkest = nil
|
||||||
for key, rect in pairs(rects) do
|
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)
|
local v = sampleLuma(key, fx, fy, fw, fh)
|
||||||
if v and (not darkest or v < darkest) then darkest = v end
|
if v and (not darkest or v < darkest) then darkest = v end
|
||||||
end
|
end
|
||||||
@@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box)
|
|||||||
return wasDark
|
return wasDark
|
||||||
end
|
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
|
-- 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
|
-- 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.
|
-- 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
|
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 ok = pcall(function()
|
||||||
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
||||||
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
|
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
|
||||||
@@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn)
|
|||||||
love.graphics.setShader()
|
love.graphics.setShader()
|
||||||
end
|
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.
|
-- The last luminance measured, for the shot driver's log.
|
||||||
function BattleHud.lastLuma()
|
function BattleHud.lastLuma()
|
||||||
local best = nil
|
local best = nil
|
||||||
@@ -344,6 +402,7 @@ function BattleHud.invalidate()
|
|||||||
frostW, frostH = 0, 0
|
frostW, frostH = 0, 0
|
||||||
luma = {}
|
luma = {}
|
||||||
wasDark = false
|
wasDark = false
|
||||||
|
layer, hudLayer = nil, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return BattleHud
|
return BattleHud
|
||||||
|
|||||||
+224
-34
@@ -9,12 +9,76 @@
|
|||||||
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
|
-- 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.
|
-- 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
|
-- So the paper is put back, and only where the paper was. Which pixels those
|
||||||
-- back once, the transparent region OUTSIDE the figure is flood-filled from
|
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
|
||||||
-- the border, and every transparent pixel the flood could not reach -- every
|
-- the hardware drew the mon's white belly and the white field behind it with
|
||||||
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
|
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
|
||||||
-- untouched, so the mon still cuts cleanly against the world; only its
|
-- image says which was which. There is no distinction to recover; there is one
|
||||||
-- insides stop being see-through.
|
-- 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
|
-- 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,
|
-- back is the pic the engine actually decided to draw -- species palette,
|
||||||
@@ -27,14 +91,23 @@ local V = ...
|
|||||||
|
|
||||||
local BattlePics = {}
|
local BattlePics = {}
|
||||||
|
|
||||||
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
|
-- Cached by the image the engine handed over, one table per bottom rule --
|
||||||
-- out of scope takes its filled twin with it rather than pinning a texture
|
-- the same pic answers differently sealed and unsealed, and a single table
|
||||||
-- for the session.
|
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
|
||||||
local cache = setmetatable({}, { __mode = "k" })
|
-- 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
|
-- What an enclosed hole is filled with when the pic itself offers nothing
|
||||||
-- battle field was: this restores the pixel the artist drew and the engine
|
-- better. White, because white is what the battle field was: this restores the
|
||||||
-- then keyed away, it does not invent a new one.
|
-- pixel the artist drew and the engine then keyed away, it does not invent a
|
||||||
|
-- new one.
|
||||||
BattlePics.FILL = { 1, 1, 1, 1 }
|
BattlePics.FILL = { 1, 1, 1, 1 }
|
||||||
|
|
||||||
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
-- 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
|
-- 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
|
-- is read -- which is also what makes this work for every path that produces
|
||||||
-- a pic, without knowing which one produced this one.
|
-- 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 function readBack(img)
|
||||||
local w, h = img:getDimensions()
|
local w, h = img:getDimensions()
|
||||||
if w <= 0 or h <= 0 then return nil end
|
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 prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||||
local data = nil
|
local data = nil
|
||||||
local ok = pcall(function()
|
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.setCanvas(canvas)
|
||||||
love.graphics.clear(0, 0, 0, 0)
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
love.graphics.setBlendMode("replace", "premultiplied")
|
love.graphics.setBlendMode("replace", "premultiplied")
|
||||||
@@ -72,32 +160,120 @@ local function readBack(img)
|
|||||||
return ok and data or nil
|
return ok and data or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Mark every transparent pixel reachable from the border. That set is the
|
-- The box the artwork actually occupies, or nil for a pic with no ink in it.
|
||||||
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
|
--
|
||||||
|
-- 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
|
-- 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
|
-- pixels and a keyed-out background is most of them, which is a deeper call
|
||||||
-- chain than is worth risking for no gain.
|
-- 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 outside = {}
|
||||||
local stack, top = {}, 0
|
local stack, top = {}, 0
|
||||||
|
local function clear(x, y)
|
||||||
|
local _, _, _, a = data:getPixel(x, y)
|
||||||
|
return a <= CUT
|
||||||
|
end
|
||||||
local function push(x, y)
|
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
|
local key = y * w + x
|
||||||
if outside[key] then return end
|
if outside[key] then return end
|
||||||
local _, _, _, a = data:getPixel(x, y)
|
if not clear(x, y) then return end
|
||||||
if a > CUT then return end
|
|
||||||
outside[key] = true
|
outside[key] = true
|
||||||
top = top + 1
|
top = top + 1
|
||||||
stack[top] = key
|
stack[top] = key
|
||||||
end
|
end
|
||||||
for x = 0, w - 1 do
|
for x = x0, x1 do push(x, y0) end
|
||||||
push(x, 0)
|
for y = y0, y1 do
|
||||||
push(x, h - 1)
|
push(x0, y)
|
||||||
|
push(x1, y)
|
||||||
end
|
end
|
||||||
for y = 0, h - 1 do
|
-- the bottom, run by run: a wide one is the gap between two legs and lets
|
||||||
push(0, y)
|
-- the world through, a narrow one is where a belly ran out and is sealed.
|
||||||
push(w - 1, y)
|
-- 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
|
end
|
||||||
while top > 0 do
|
while top > 0 do
|
||||||
local key = stack[top]
|
local key = stack[top]
|
||||||
@@ -114,9 +290,15 @@ end
|
|||||||
-- The pic with its enclosed holes filled, or the pic itself when that could
|
-- 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
|
-- 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.
|
-- 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
|
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
|
if hit ~= nil then return hit or img end
|
||||||
|
|
||||||
local made = nil
|
local made = nil
|
||||||
@@ -124,16 +306,24 @@ function BattlePics.filled(img)
|
|||||||
local data = readBack(img)
|
local data = readBack(img)
|
||||||
if not data then return end
|
if not data then return end
|
||||||
local w, h = data:getDimensions()
|
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 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
|
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
|
local row = y * w
|
||||||
for x = 0, w - 1 do
|
for x = x0, x1 do
|
||||||
if not outside[row + x] then
|
if not outside[row + x] then
|
||||||
local _, _, _, a = data:getPixel(x, y)
|
local _, _, _, a = data:getPixel(x, y)
|
||||||
if a <= CUT then
|
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
|
changed = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -146,12 +336,12 @@ function BattlePics.filled(img)
|
|||||||
made = out
|
made = out
|
||||||
end)
|
end)
|
||||||
|
|
||||||
cache[img] = (ok and made) or false
|
slot[img] = (ok and made) or false
|
||||||
return made or img
|
return made or img
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattlePics.invalidate()
|
function BattlePics.invalidate()
|
||||||
cache = setmetatable({}, { __mode = "k" })
|
cache = newCache()
|
||||||
end
|
end
|
||||||
|
|
||||||
return BattlePics
|
return BattlePics
|
||||||
|
|||||||
+241
-19
@@ -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 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
|
-- 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 BattleCam = V.require("BattleCam")
|
||||||
local BattleBillboard = V.require("BattleBillboard")
|
local BattleBillboard = V.require("BattleBillboard")
|
||||||
local VoxelGrid = V.require("VoxelGrid")
|
local VoxelGrid = V.require("VoxelGrid")
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
local AntiAlias = V.require("AntiAlias")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Map = require("src.world.Map")
|
||||||
|
|
||||||
local BattleScene = {}
|
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
|
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
|
||||||
ChunkMesher.setLive(live)
|
ChunkMesher.setLive(live)
|
||||||
TerrainAtlas.setLive(live)
|
TerrainAtlas.setLive(live)
|
||||||
local terrain = ChunkMesher.request(host, false, nil, true)
|
ChunkMesher.request(host, false, nil, true)
|
||||||
or ChunkMesher.peek(host, true)
|
local terrain, water = ChunkMesher.pair(host, false)
|
||||||
return terrain, {}
|
if not terrain then terrain, water = ChunkMesher.pair(host, true) end
|
||||||
|
return terrain, {}, water, {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- the sun
|
-- ------- the sun
|
||||||
@@ -206,6 +210,91 @@ end
|
|||||||
|
|
||||||
BattleScene.monCards = monCards
|
BattleScene.monCards = monCards
|
||||||
|
|
||||||
|
-- The MOVE-ANIMATION layer's place in the world: a BILLBOARD facing the
|
||||||
|
-- eye, for the GB-frame effects texture OverworldBattle.animTexture
|
||||||
|
-- renders (the engine's own drawAnimLayer, caught on a canvas).
|
||||||
|
--
|
||||||
|
-- Effects are 2D drawings like the pics, and the pics' answer holds for
|
||||||
|
-- them too: a drawing must FACE the eye that is looking (the mon cards
|
||||||
|
-- yaw toward it per eye -- see monMatrix). So the frame stands on the
|
||||||
|
-- arena's midpoint, yawed at the eye like the cards are, and the classic
|
||||||
|
-- layout's two slot marks are pinned where each CELL lands on that plane
|
||||||
|
-- along this very eye's own ray -- so from the eye that is looking, a
|
||||||
|
-- burst authored at a slot sits exactly over the mon standing in for it,
|
||||||
|
-- and a projectile crossing the frame crosses the arena. The vertical
|
||||||
|
-- scale is the mon cards' own (FULL_W / FULL_PIC), so an effect is sized
|
||||||
|
-- like the pics it plays over.
|
||||||
|
--
|
||||||
|
-- An eye standing (nearly) ON the arena's axis sees the two cells in
|
||||||
|
-- line and the pinning degenerates; the frame then falls back to the
|
||||||
|
-- fixed plane through both cells, which that eye views edge-on anyway.
|
||||||
|
--
|
||||||
|
-- Reads Voxel3D.eye at CALL time, like the cards -- call it per eye.
|
||||||
|
-- Returns the model matrix for BattleBillboard's unit card (x -0.5..0.5,
|
||||||
|
-- y 0..1 up, v flipped), or nil where the anchors are degenerate.
|
||||||
|
function BattleScene.fxCard(arena, groundY, anchors)
|
||||||
|
local p, e = anchors.player, anchors.enemy
|
||||||
|
local dgb = e[1] - p[1]
|
||||||
|
if math.abs(dgb) < 1 then return nil end
|
||||||
|
local GW, GH = BattleScene.GB_W, BattleScene.GB_H
|
||||||
|
local Px, Py, Pz = arena.player[1], groundY, arena.player[2]
|
||||||
|
local Ex, Ey, Ez = arena.enemy[1], groundY, arena.enemy[2]
|
||||||
|
local s = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
|
||||||
|
local Mx, My, Mz = (Px + Ex) / 2, groundY, (Pz + Ez) / 2
|
||||||
|
|
||||||
|
local eye = Voxel3D.eye
|
||||||
|
local yaw = BattleBillboard.yawToward(Mx, Mz, eye)
|
||||||
|
local nx, nz = math.sin(yaw), math.cos(yaw) -- out of the frame, at the eye
|
||||||
|
local rx, rz = math.cos(yaw), -math.sin(yaw) -- the frame's own right
|
||||||
|
|
||||||
|
-- where a world point sits ON the billboard, as (right, up) coordinates
|
||||||
|
-- about the midpoint: slid along the eye's ray onto the plane, so the
|
||||||
|
-- mark and the mon line up from exactly the seat that is looking
|
||||||
|
local function inPlane(qx_, qy_, qz_)
|
||||||
|
if eye then
|
||||||
|
local dqx, dqy, dqz = qx_ - eye[1], qy_ - eye[2], qz_ - eye[3]
|
||||||
|
local denom = dqx * nx + dqz * nz
|
||||||
|
if math.abs(denom) > 1e-6 then
|
||||||
|
local t = ((Mx - eye[1]) * nx + (Mz - eye[3]) * nz) / denom
|
||||||
|
qx_ = eye[1] + dqx * t
|
||||||
|
qy_ = eye[2] + dqy * t
|
||||||
|
qz_ = eye[3] + dqz * t
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return (qx_ - Mx) * rx + (qz_ - Mz) * rz, qy_ - My
|
||||||
|
end
|
||||||
|
local pax, pay = inPlane(Px, Py, Pz)
|
||||||
|
local eax, eay = inPlane(Ex, Ey, Ez)
|
||||||
|
|
||||||
|
if math.abs(eax - pax) < 4 then
|
||||||
|
-- edge-on: the fixed plane through both cells, world-axis mapping
|
||||||
|
local ux = (Ex - Px) / dgb
|
||||||
|
local uy = (Ey - Py - s * (p[2] - e[2])) / dgb
|
||||||
|
local uz = (Ez - Pz) / dgb
|
||||||
|
local cx = Px + ux * (0.5 * GW - p[1])
|
||||||
|
local cy = Py + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
|
||||||
|
local cz = Pz + uz * (0.5 * GW - p[1])
|
||||||
|
local nl = math.sqrt(ux * ux + uz * uz)
|
||||||
|
local fx, fz = 0, 1
|
||||||
|
if nl > 1e-9 then fx, fz = uz / nl, -ux / nl end
|
||||||
|
return { ux * GW, 0, fx, cx,
|
||||||
|
uy * GW, s * GH, 0, cy,
|
||||||
|
uz * GW, 0, fz, cz,
|
||||||
|
0, 0, 0, 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- in-plane travel per GB pixel of frame x, solved so both marks land:
|
||||||
|
-- inPlane(gb) = (pax, pay) + U * (gbx - p.x) + (0, s) * (p.y - gby)
|
||||||
|
local ux = (eax - pax) / dgb
|
||||||
|
local uy = (eay - pay - s * (p[2] - e[2])) / dgb
|
||||||
|
local cxp = pax + ux * (0.5 * GW - p[1])
|
||||||
|
local cyp = pay + uy * (0.5 * GW - p[1]) + s * (p[2] - GH)
|
||||||
|
return { rx * ux * GW, 0, nx, Mx + rx * cxp,
|
||||||
|
uy * GW, s * GH, 0, My + cyp,
|
||||||
|
rz * ux * GW, 0, nz, Mz + rz * cxp,
|
||||||
|
0, 0, 0, 1 }
|
||||||
|
end
|
||||||
|
|
||||||
-- The sun has to see the mons too, or they stand on the ground without
|
-- The sun has to see the mons too, or they stand on the ground without
|
||||||
-- putting anything on it. They are the one thing in this scene that MOVES,
|
-- putting anything on it. They are the one thing in this scene that MOVES,
|
||||||
-- so `token` -- a counter the caller bumps whenever a pic could have changed
|
-- so `token` -- a counter the caller bumps whenever a pic could have changed
|
||||||
@@ -215,13 +304,18 @@ BattleScene.monCards = monCards
|
|||||||
local function shadowSignature(state, arena, terrain, nbMesh, token)
|
local function shadowSignature(state, arena, terrain, nbMesh, token)
|
||||||
local host = arena.map or state.map
|
local host = arena.map or state.map
|
||||||
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
|
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
|
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
|
||||||
return table.concat(parts, ",")
|
return table.concat(parts, ",")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
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
|
if not ShadowMap.available() then return end
|
||||||
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
||||||
if not ShadowMap.stale(sig) then return end
|
if not ShadowMap.stale(sig) then return end
|
||||||
@@ -231,18 +325,35 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
|||||||
for i, nb in ipairs(neighbors) do
|
for i, nb in ipairs(neighbors) do
|
||||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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
|
for _, nb in ipairs(neighbors) do
|
||||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
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
|
end
|
||||||
|
|
||||||
-- the mons themselves, as the same cards the camera will see. Their alpha
|
-- 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
|
-- is the silhouette, so what lands on the ground is the shape of the
|
||||||
-- Pokemon rather than a blob standing in for one.
|
-- 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
|
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
|
end
|
||||||
|
ShadowMap.sprites(false)
|
||||||
|
|
||||||
ShadowMap.finish(sig)
|
ShadowMap.finish(sig)
|
||||||
end
|
end
|
||||||
@@ -278,18 +389,72 @@ end
|
|||||||
-- yet (the terrain mesh is still building, the driver has no depth support).
|
-- yet (the terrain mesh is still building, the driver has no depth support).
|
||||||
-- nil is not a failure: the caller simply leaves the battle screen as the
|
-- nil is not a failure: the caller simply leaves the battle screen as the
|
||||||
-- engine drew it for that frame.
|
-- engine drew it for that frame.
|
||||||
|
-- White, for the hit flash, and how far toward it the card goes.
|
||||||
|
--
|
||||||
|
-- The shader replaces the card's colour rather than multiplying it, so at
|
||||||
|
-- full strength this is the sprite turned into a solid white silhouette --
|
||||||
|
-- which is what the effect is on a flat GB screen and far too much on a
|
||||||
|
-- sprite standing in a lit world. Held well short of 1, the mon's own
|
||||||
|
-- shading still reads through the flash: it looks struck rather than
|
||||||
|
-- deleted.
|
||||||
|
BattleScene.FLASH_COLOR = { 1, 1, 1 }
|
||||||
|
BattleScene.FLASH_STRENGTH = 0.5
|
||||||
|
|
||||||
|
-- ------- the tile clock, while the overworld is not the one drawing
|
||||||
|
--
|
||||||
|
-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE
|
||||||
|
-- only advances it from OverworldState:drawWorld -- which runs under dialogs
|
||||||
|
-- and menus, but not under a battle, because a battle draws instead of the
|
||||||
|
-- overworld rather than over it. So for the length of a staged fight the
|
||||||
|
-- counter stood still: the water tiles stopped rotating their pixels and the
|
||||||
|
-- wave field, which is driven off the same number so the two cannot drift
|
||||||
|
-- (see Water), stopped with them. A lake in the background of a battle was a
|
||||||
|
-- photograph.
|
||||||
|
--
|
||||||
|
-- Ticked HERE rather than from the mod's update hook, because here is the
|
||||||
|
-- one place that means "a staged battle is drawing this frame, and the
|
||||||
|
-- overworld is not". From the update hook the condition would have to be
|
||||||
|
-- guessed at, and a frame where both ran would double the rate.
|
||||||
|
local function tickTiles()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local ow = Game and Game.overworld
|
||||||
|
local top = Game and Game.stack and Game.stack:top()
|
||||||
|
-- during the wipe INTO a battle the overworld can still be the one
|
||||||
|
-- drawing, and it is ticking the clock itself; two ticks in a frame would
|
||||||
|
-- run the water at double speed
|
||||||
|
if top and ow and top == ow then return end
|
||||||
|
pcall(require("src.render.TileRenderer").tick)
|
||||||
|
end
|
||||||
|
|
||||||
function BattleScene.render(state, arena, textures, token)
|
function BattleScene.render(state, arena, textures, token)
|
||||||
if not (state and state.map and arena) then return nil end
|
if not (state and state.map and arena) then return nil end
|
||||||
if not Voxel3D.available() 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
|
-- the floor the fight is staged on: normally the player's own, sometimes
|
||||||
-- another floor of the same cave or building (see BattleArena)
|
-- another floor of the same cave or building (see BattleArena)
|
||||||
local host = arena.map or state.map
|
local host = arena.map or state.map
|
||||||
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
|
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
|
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
|
||||||
-- exactly the meshes walking around would have and nothing extra
|
-- 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
|
if not terrain then return nil end
|
||||||
|
|
||||||
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
||||||
@@ -319,7 +484,7 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
local cards = monCards(arena, groundY, textures)
|
local cards = monCards(arena, groundY, textures)
|
||||||
Voxel3D.camera = nil
|
Voxel3D.camera = nil
|
||||||
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
|
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
|
-- 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
|
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
|
||||||
@@ -331,9 +496,12 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
|
|
||||||
Voxel3D.camera = cam
|
Voxel3D.camera = cam
|
||||||
-- the sun is turned up for the arena and put back afterwards, so the
|
-- 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
|
local sunWas = Voxel3D.SHADOW_ALPHA
|
||||||
Voxel3D.SHADOW_ALPHA = BattleScene.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
|
-- 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
|
-- 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
|
-- the seams are what make it read as built rather than photographed. Forced
|
||||||
@@ -345,7 +513,16 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
-- its own canvas slot: this renders at the window's pixel size and the
|
-- 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
|
-- 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
|
-- 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
|
return
|
||||||
end
|
end
|
||||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||||
@@ -353,15 +530,53 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||||
Mat4.translate(nb.ox, 0, nb.oy))
|
Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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,
|
-- 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
|
-- 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
|
-- front of it, and the alpha discard cuts the sprite's own outline out of
|
||||||
-- the card. A small camera-ward pull keeps a card rooted to the ground
|
-- the card. A small camera-ward pull keeps a card rooted to the ground
|
||||||
-- plane from z-fighting the tile it is standing on.
|
-- plane from z-fighting the tile it is standing on.
|
||||||
for _, card in ipairs(monCards(arena, groundY, textures)) do
|
-- The engine's hit flash is a full-screen white rectangle, which on a
|
||||||
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
-- white battle field is a flash and over a world is a whiteout of the
|
||||||
BattleBillboard.PULL)
|
-- map, the HUD and the text box alike. It is dropped on the way past
|
||||||
|
-- (see OverworldBattle) and put back HERE, on the two things it was ever
|
||||||
|
-- about: the mons themselves go solid white for those frames.
|
||||||
|
local flashing = textures and textures.flash
|
||||||
|
if flashing then
|
||||||
|
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
|
||||||
end
|
end
|
||||||
|
-- and no voxel wireframe on the pair. Everything else in this frame is
|
||||||
|
-- built a unit per voxel and wears the seams that fall out of that; a
|
||||||
|
-- mon's card is one quad wearing the battle screen (see
|
||||||
|
-- BattleBillboard), so it is off the grid and has no seams to draw.
|
||||||
|
Voxel3D.seams(false)
|
||||||
|
-- and no glass either: the cards wear the battle screen, not the
|
||||||
|
-- tileset atlas, so the mask's coordinates mean nothing on them
|
||||||
|
Voxel3D.glass(false)
|
||||||
|
for _, card in ipairs(monCards(arena, groundY, textures)) do
|
||||||
|
-- the sun stored this card snugged (castShadows), so its own shadow
|
||||||
|
-- lookup must read the same snugged transform -- see ShadowMap.snug
|
||||||
|
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
||||||
|
BattleBillboard.PULL, ShadowMap.snug(card.model))
|
||||||
|
end
|
||||||
|
Voxel3D.glass(true)
|
||||||
|
Voxel3D.seams(true)
|
||||||
|
if flashing then Voxel3D.flatten(nil) end
|
||||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||||
-- gives them, measured against THIS camera's pitch rather than the
|
-- gives them, measured against THIS camera's pitch rather than the
|
||||||
-- orbit's -- there is no character here for them to overdraw, but the
|
-- orbit's -- there is no character here for them to overdraw, but the
|
||||||
@@ -373,12 +588,14 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||||
end
|
end
|
||||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
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
|
for _, nb in ipairs(neighbors) do
|
||||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
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
|
end
|
||||||
local canvas = Voxel3D.endScene()
|
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
|
||||||
if not canvas then return end
|
if not canvas then return end
|
||||||
|
|
||||||
local vp = Voxel3D.vp
|
local vp = Voxel3D.vp
|
||||||
@@ -409,6 +626,11 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
-- 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
|
-- the two marks rather than on a fraction of the window
|
||||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
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)
|
end)
|
||||||
-- the placed camera is ours for exactly this pass; anything else that
|
-- 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,
|
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
|
||||||
side = 0.78, bottom = 0.5 }
|
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)
|
local function keyOf(tx, ty)
|
||||||
return (ty + 64) * 4096 + (tx + 64)
|
return (ty + 64) * 4096 + (tx + 64)
|
||||||
end
|
end
|
||||||
@@ -170,6 +211,39 @@ local function read(t, data, perRow)
|
|||||||
|
|
||||||
local inside = {}
|
local inside = {}
|
||||||
for i = 0, W * H - 1 do inside[i] = not outside[i] end
|
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 }
|
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -190,7 +264,24 @@ local function measure(sp, t)
|
|||||||
top[x] = r
|
top[x] = r
|
||||||
end
|
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
|
local ytop = wallH - 1 + t.slab
|
||||||
|
|
||||||
-- Side faces must not come out as slabs of outline black: where the
|
-- Side faces must not come out as slabs of outline black: where the
|
||||||
@@ -259,6 +350,14 @@ local function measure(sp, t)
|
|||||||
end
|
end
|
||||||
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:
|
-- One representative texel per shade, taken from the building's own art:
|
||||||
-- the roof's fascia and its undersides are geometry the drawing implies
|
-- 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
|
-- 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
|
-- 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
|
-- 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.
|
-- 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 }
|
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ----------------------------------------------------------------- build --
|
-- ----------------------------------------------------------------- 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
|
-- 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
|
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||||
-- are trimmed to its underside so nothing pokes through the surface.
|
-- are trimmed to its underside so nothing pokes through the surface.
|
||||||
local function model(sp, pr, t)
|
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 W, H, D = sp.W, sp.H, pr.D
|
||||||
local slab, roofRows = t.slab, t.roofRows
|
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
|
-- 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
|
-- 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
|
-- 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
|
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
|
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
||||||
return sy * W + x
|
return sy * W + x
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
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
|
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
|
local i = sy * W + x
|
||||||
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
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
|
-- 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 function runX(y, z, dx, dy, dz, x)
|
||||||
local i0 = ci(x, y, z)
|
local i0 = ci(x, y, z)
|
||||||
local strip, n = nil, 1
|
local strip, n = nil, 1
|
||||||
while true do
|
local cap = runCap(x)
|
||||||
|
while n < cap do
|
||||||
local nx = x + n
|
local nx = x + n
|
||||||
local i = ci(nx, y, z)
|
local i = ci(nx, y, z)
|
||||||
if not i or ci(nx + dx, y + dy, z + dz) then break end
|
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
|
while z <= zmax do
|
||||||
local i = ci(x, y, z)
|
local i = ci(x, y, z)
|
||||||
if i and not ci(x + d, y, z) then
|
if i and not ci(x + d, y, z) then
|
||||||
local n = 1
|
local n, cap = 1, runCap(z)
|
||||||
while z + n <= zmax do
|
while n < cap and z + n <= zmax do
|
||||||
local j = ci(x, y, z + n)
|
local j = ci(x, y, z + n)
|
||||||
if j ~= i or ci(x + d, y, z + n) then break end
|
if j ~= i or ci(x + d, y, z + n) then break end
|
||||||
n = n + 1
|
n = n + 1
|
||||||
@@ -666,7 +1258,7 @@ function Buildings.build(S, map, data, perRow)
|
|||||||
end
|
end
|
||||||
built = models[key]
|
built = models[key]
|
||||||
end
|
end
|
||||||
Buildings.stamp(S, map, built, tx, ty, bw, bh)
|
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -676,9 +1268,24 @@ end
|
|||||||
|
|
||||||
-- One placement: claim its tiles (so the detector leaves them alone and
|
-- One placement: claim its tiles (so the detector leaves them alone and
|
||||||
-- the mesher paints ground under them) and copy the model into place.
|
-- 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",
|
-- Two template fields alter what a claim means, for a drawing that
|
||||||
flat = false, authored = true }
|
-- 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
|
-- the ground the building stands on: the commonest flat tile around its
|
||||||
-- feet, so a house on a path keeps its path
|
-- 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 r = 0, bh - 1 do
|
||||||
for c = 0, bw - 1 do
|
for c = 0, bw - 1 do
|
||||||
local k = keyOf(tx + c, ty + r)
|
local k = keyOf(tx + c, ty + r)
|
||||||
S.shapeAt[k] = shape
|
if keep and keep[S.tileAt[k]] then
|
||||||
S.skip[k] = true
|
-- unclaimed by request: the tile keeps its pin (the plant's
|
||||||
S.ground[k] = best or false
|
-- 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+145
-17
@@ -221,8 +221,18 @@ end
|
|||||||
-- Kept free of any GPU call so it can be exercised headless -- the
|
-- 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
|
-- 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.
|
-- 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 push = sink.push
|
||||||
|
local waterPush = waterSink and waterSink.push or nil
|
||||||
local tileset = map.tileset
|
local tileset = map.tileset
|
||||||
local S = Structures.forMap(map)
|
local S = Structures.forMap(map)
|
||||||
local perRow = tileset.tilesPerRow or 16
|
local perRow = tileset.tilesPerRow or 16
|
||||||
@@ -358,12 +368,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
|||||||
return aoSide
|
return aoSide
|
||||||
end
|
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)
|
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||||
push({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||||
aoShades(x0 / 8, z0 / 8, h, shade))
|
aoShades(x0 / 8, z0 / 8, h, shade))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vertical quad for face direction `d` of the tile column at (x0, z0),
|
-- 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
|
s = nil
|
||||||
end
|
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
|
if s and S.skip[k] then
|
||||||
-- an object stands here; paint its synthesized ground and let the
|
-- an object stands here; paint its synthesized ground and let the
|
||||||
-- prebuilt prism quads (appended below) carry the art
|
-- prebuilt prism quads (appended below) carry the art
|
||||||
@@ -546,8 +570,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
|||||||
end
|
end
|
||||||
topTile = S.tileAt[keyOf(tx, row)]
|
topTile = S.tileAt[keyOf(tx, row)]
|
||||||
end
|
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,
|
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
|
end
|
||||||
|
|
||||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
-- 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
|
-- The raw geometry for `map`: (vertex list, triangle index list, quad
|
||||||
-- count). Synchronous and GPU-free -- the headless suite and the probes
|
-- count). Synchronous and GPU-free -- the headless suite and the probes
|
||||||
-- exercise the invariants through this.
|
-- 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()
|
local sink = newTableSink()
|
||||||
runGeometry(map, bodyOnly, masks, sink)
|
local waterSink = split and newTableSink() or nil
|
||||||
return sink.results()
|
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
|
end
|
||||||
|
|
||||||
-- Build the mesh for `map` synchronously. Returns nil when there is
|
-- Build the mesh for `map` synchronously. Returns nil when there is
|
||||||
-- nothing to draw or meshes are unavailable (headless).
|
-- 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()
|
local sink = newSink()
|
||||||
runGeometry(map, bodyOnly, masks, sink)
|
local waterSink = split and newSink() or nil
|
||||||
return sink.finish()
|
runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||||
|
return sink.finish(), waterSink and waterSink.finish() or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function quadsMesh(quads)
|
local function quadsMesh(quads)
|
||||||
@@ -801,6 +847,43 @@ local function buildFlowerMesh(map)
|
|||||||
return quadsMesh(Structures.forMap(map).flowerQuads)
|
return quadsMesh(Structures.forMap(map).flowerQuads)
|
||||||
end
|
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.
|
-- Replace a cached slot, releasing whatever mesh it held.
|
||||||
local function swapSlot(c, slot, mesh)
|
local function swapSlot(c, slot, mesh)
|
||||||
local old = c[slot]
|
local old = c[slot]
|
||||||
@@ -819,12 +902,23 @@ local function entry(id)
|
|||||||
return c
|
return c
|
||||||
end
|
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)
|
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]
|
local mesh = c[slot]
|
||||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||||
c[slot] = nil
|
c[slot] = nil
|
||||||
end
|
end
|
||||||
|
releaseFigures(c.figures)
|
||||||
|
c.figures = nil
|
||||||
c.stale = nil
|
c.stale = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -863,28 +957,37 @@ end
|
|||||||
local function runJob(job)
|
local function runJob(job)
|
||||||
local map = job.map
|
local map = job.map
|
||||||
local c = entry(job.id)
|
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 okG, grass = pcall(buildGrassMesh, map)
|
||||||
local okF, flowers = pcall(buildFlowerMesh, map)
|
local okF, flowers = pcall(buildFlowerMesh, map)
|
||||||
|
local okX, figures = pcall(buildFigureMeshes, map)
|
||||||
if (gen[job.id] or 0) ~= job.gen then
|
if (gen[job.id] or 0) ~= job.gen then
|
||||||
if okG and grass and grass.release then pcall(grass.release, grass) end
|
if okG and grass and grass.release then pcall(grass.release, grass) end
|
||||||
if okF and flowers and flowers.release then
|
if okF and flowers and flowers.release then
|
||||||
pcall(flowers.release, flowers)
|
pcall(flowers.release, flowers)
|
||||||
end
|
end
|
||||||
|
if okX then releaseFigures(figures) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
swapSlot(c, "grass", (okG and grass) or false)
|
swapSlot(c, "grass", (okG and grass) or false)
|
||||||
swapSlot(c, "flowers", (okF and flowers) 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
|
if c.stale then c.stale.aux = nil end
|
||||||
end
|
end
|
||||||
local sink = newSink()
|
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 mesh = sink.finish()
|
||||||
|
local water = waterSink.finish()
|
||||||
if (gen[job.id] or 0) ~= job.gen then
|
if (gen[job.id] or 0) ~= job.gen then
|
||||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||||
|
if water and water.release then pcall(water.release, water) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
swapSlot(c, job.slot, mesh or false)
|
swapSlot(c, job.slot, mesh or false)
|
||||||
|
swapSlot(c, waterSlot(job.slot), water or false)
|
||||||
if c.stale then
|
if c.stale then
|
||||||
c.stale[job.slot] = nil
|
c.stale[job.slot] = nil
|
||||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
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
|
if c.stale then c.stale.aux = nil end
|
||||||
end
|
end
|
||||||
if c[slot] == nil or (c.stale and c.stale[slot]) then
|
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
|
if not ok then
|
||||||
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
||||||
.. ": " .. tostring(mesh))
|
.. ": " .. tostring(mesh))
|
||||||
end
|
end
|
||||||
swapSlot(c, slot, (ok and mesh) or false)
|
swapSlot(c, slot, (ok and mesh) or false)
|
||||||
|
swapSlot(c, waterSlot(slot), (ok and water) or false)
|
||||||
if c.stale then
|
if c.stale then
|
||||||
c.stale[slot] = nil
|
c.stale[slot] = nil
|
||||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
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
|
return mesh or nil
|
||||||
end
|
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)
|
function ChunkMesher.grass(map)
|
||||||
local c = cache[map.id]
|
local c = cache[map.id]
|
||||||
return c and c.grass or nil
|
return c and c.grass or nil
|
||||||
@@ -1022,6 +1142,14 @@ function ChunkMesher.flowers(map)
|
|||||||
return c and c.flowers or nil
|
return c and c.flowers or nil
|
||||||
end
|
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
|
-- 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
|
-- replacements cook, and each slot swaps as its build lands. This is
|
||||||
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
|
-- 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,763 @@
|
|||||||
|
-- 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
|
||||||
|
|
||||||
|
-- A multiplier on the first-person field of view, for anything that wants
|
||||||
|
-- to narrow the lens without owning the rig: 1 is the ordinary 65
|
||||||
|
-- degrees, and horde mode's iron sights ease it down toward 40 while the
|
||||||
|
-- player is looking down them (lib/HordeGun). Kept here rather than in
|
||||||
|
-- the caller because the fov is folded into the orbit blend below, and
|
||||||
|
-- because signature() has to know -- a lens that narrows while the player
|
||||||
|
-- stands still still has to re-fit the shadow box.
|
||||||
|
FirstPerson.fovScale = 1
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
-- A VR eye stepping into the rig's shoes: the VR pass builds its own
|
||||||
|
-- placed cameras (one per eye) and hands each one here as it draws, so
|
||||||
|
-- everything keyed to "the first-person rig is drawing" -- the billboard
|
||||||
|
-- yaw, the frame remap, the hidden player card -- answers for that eye.
|
||||||
|
-- In the diorama (blend 0) adoption is inert: cardBlend still reports
|
||||||
|
-- zero and the cards keep their lean.
|
||||||
|
function FirstPerson.adoptVReye(record)
|
||||||
|
rig = record
|
||||||
|
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 * FirstPerson.fovScale - 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),
|
||||||
|
math.floor(FirstPerson.fovScale * 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.
|
||||||
|
--
|
||||||
|
-- HORDE MODE re-reads the same two buttons as a weapon: left fires,
|
||||||
|
-- right holds the sights. Claimed BEFORE the A/B mapping below rather
|
||||||
|
-- than on top of it, so a click during the mode never also lands as a
|
||||||
|
-- GB button -- otherwise the A that ends the GAME OVER card would be
|
||||||
|
-- spent by the shot that ended the run.
|
||||||
|
local mouseHeld = {}
|
||||||
|
local MOUSE_BTN = { [1] = "a", [2] = "b" }
|
||||||
|
local function hordeMouse(button, down)
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
if not Horde.playing() then return false end
|
||||||
|
if button == 1 then
|
||||||
|
if down then V.require("HordeGun").fire() end
|
||||||
|
return true
|
||||||
|
elseif button == 2 then
|
||||||
|
V.require("HordeGun").setAds(down)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
do
|
||||||
|
local inner = love.mousepressed
|
||||||
|
love.mousepressed = function(x, y, button, istouch, presses)
|
||||||
|
if captured and not istouch and hordeMouse(button, true) then return end
|
||||||
|
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)
|
||||||
|
-- a release always reaches whoever owns the press: the horde's
|
||||||
|
-- aim-hold has to let go even if the mode ended mid-click
|
||||||
|
if not mouseHeld[button] and hordeMouse(button, false) then return end
|
||||||
|
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
|
||||||
|
-- HORDE MODE: a tap on open screen is a SHOT, fired on the press
|
||||||
|
-- rather than on a release that turned out not to be a drag --
|
||||||
|
-- a shooter that waits to find out whether you meant it is a
|
||||||
|
-- shooter that misses. The same finger still becomes the look
|
||||||
|
-- drag below, so aiming and firing are one gesture.
|
||||||
|
if V.require("Horde").playing() then
|
||||||
|
V.require("HordeGun").fire()
|
||||||
|
end
|
||||||
|
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,353 @@
|
|||||||
|
-- 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, and the route-gate warp
|
||||||
|
-- fired by collision. 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.
|
||||||
|
--
|
||||||
|
-- The one verb NOT restated here is the bonk. On the grid a blocked step
|
||||||
|
-- is a discrete event -- you pressed a direction, the game refused, and
|
||||||
|
-- the bump answers you once. A free walk has no such moment: the body
|
||||||
|
-- SLIDES along whatever it grazes, so a player walking a fence line or
|
||||||
|
-- rounding a doorframe is blocked on one axis continuously, and the same
|
||||||
|
-- sound comes out as a rattle for as long as they keep walking. It is
|
||||||
|
-- feedback for a refusal that is not happening. The grid walk keeps its
|
||||||
|
-- own bump (the engine's, in OverworldController) untouched.
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
|
||||||
|
-- HORDE MODE takes both of these away for as long as it runs: there is
|
||||||
|
-- no pausing (START), and nobody stops to read a sign with the horde
|
||||||
|
-- coming (A, which is also the button the mode's own GAME OVER card
|
||||||
|
-- wants left unspent). Everything below -- the walk, the wall slide and
|
||||||
|
-- the blocked-push verbs, warps included -- keeps working, because the
|
||||||
|
-- crowd has to be able to follow the player through a door.
|
||||||
|
local suppressed = V.require("Horde").suppressWorldInput()
|
||||||
|
|
||||||
|
if not suppressed and input:wasPressed("a") then
|
||||||
|
state:interact()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not suppressed and 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
|
||||||
|
|
||||||
|
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
|
||||||
+707
@@ -0,0 +1,707 @@
|
|||||||
|
-- HORDE MODE: the code, the dark, and the way back.
|
||||||
|
--
|
||||||
|
-- Up Up Down Down Left Right Left Right B A, standing in the overworld,
|
||||||
|
-- and Kanto turns on you: the sky goes to a starless violet night, the
|
||||||
|
-- Lavender Town theme comes up, the camera locks into the player's own
|
||||||
|
-- head, a handgun appears in their right hand, and waves of people walk
|
||||||
|
-- out of the dark to kill them. Score goes up per kill; when the health
|
||||||
|
-- runs out a GAME OVER screen offers a score and PRESS A, and pressing it
|
||||||
|
-- puts everything back exactly as it was.
|
||||||
|
--
|
||||||
|
-- WHAT THIS FILE OWNS: the code detector, the state machine, the snapshot
|
||||||
|
-- and its restore, and every hook that holds the world still while the
|
||||||
|
-- mode runs. The gun is lib/HordeGun, the crowd is lib/HordeMobs, the
|
||||||
|
-- readout is lib/HordeHud, the sounds are lib/HordeSfx and the ending is
|
||||||
|
-- lib/HordeGameOver.
|
||||||
|
--
|
||||||
|
-- IT IS NOT A STACK STATE, and that is the load-bearing decision. Pushing
|
||||||
|
-- a state over the overworld stops StateStack ticking the overworld,
|
||||||
|
-- which stops OverworldState:handleInput, which stops FreeMove -- the
|
||||||
|
-- player would be unable to walk. So horde mode is a MODE FLAG driven
|
||||||
|
-- from the voxel pipeline's update hook, exactly as lib/OverworldBattle
|
||||||
|
-- rides it: the one tick that keeps running through menus, transitions
|
||||||
|
-- and battles. The GAME OVER screen IS a pushed state, because by then
|
||||||
|
-- the walking is over and freezing the world under it is the point.
|
||||||
|
--
|
||||||
|
-- THE CODE IS READ OFF GAME BOY BUTTONS, not off keys. Every input device
|
||||||
|
-- the engine has -- keyboard, gamepad, raw joystick, the touch overlay,
|
||||||
|
-- and the VR controllers (lib/VR.driveControls feeds Input:overlayPressed
|
||||||
|
-- and the stick path) -- lands in src/core/Input as one of eight buttons.
|
||||||
|
-- One detector on that abstraction is therefore a detector on ALL of
|
||||||
|
-- them, which is why the code works on a headset with no keyboard in the
|
||||||
|
-- room. It reads Input.pressQueue from the `input.step` hook, the fixed
|
||||||
|
-- step's own boundary, so it sees every edge exactly once whatever the
|
||||||
|
-- frame rate did.
|
||||||
|
--
|
||||||
|
-- THE DARK is not a new renderer. DayNight is pinned to NIGHT and then
|
||||||
|
-- its two public colour functions are WRAPPED and multiplied down toward
|
||||||
|
-- violet -- so the sky bands, the world tint, the flat 2D world (DayTint
|
||||||
|
-- paints the same multiply), the water's reflection and the shadow rig
|
||||||
|
-- all darken together, because every one of them already reads those two
|
||||||
|
-- functions. Wrapped rather than edited in place because both memoise
|
||||||
|
-- into file-local caches this module cannot reach.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Voxel = V.require("VoxelState")
|
||||||
|
local Voxel3D = V.require("Voxel3D")
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
local FirstPerson = V.require("FirstPerson")
|
||||||
|
local HordeSfx = V.require("HordeSfx")
|
||||||
|
|
||||||
|
local Horde = {}
|
||||||
|
|
||||||
|
-- lib modules that require THIS one back (the mobs read the session, the
|
||||||
|
-- gun reports kills). Loaded on first use rather than at the top, so the
|
||||||
|
-- require cycle never closes.
|
||||||
|
local Mobs, Gun, Hud
|
||||||
|
local function parts()
|
||||||
|
Mobs = Mobs or V.require("HordeMobs")
|
||||||
|
Gun = Gun or V.require("HordeGun")
|
||||||
|
Hud = Hud or V.require("HordeHud")
|
||||||
|
return Mobs, Gun, Hud
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- tuning
|
||||||
|
--
|
||||||
|
-- Every number the mode is balanced on, in one place.
|
||||||
|
|
||||||
|
Horde.MAX_HP = 100
|
||||||
|
Horde.CONTACT_DAMAGE = 9 -- one mob's touch
|
||||||
|
Horde.INTRO_TIME = 3.6 -- the beat before the first wave
|
||||||
|
Horde.DYING_TIME = 1.1 -- from the last hit to the GAME OVER card
|
||||||
|
Horde.SONG = "Music_Lavender"
|
||||||
|
|
||||||
|
-- how far down NIGHT is dragged. The sky's bands and the world tint are
|
||||||
|
-- multiplied by these; the third is how much of the colour is pulled out
|
||||||
|
-- on the way (1 keeps it, 0 is greyscale) -- a little desaturation is
|
||||||
|
-- what turns "dark" into "grim".
|
||||||
|
Horde.GLOOM_SKY = { 0.34, 0.30, 0.46 }
|
||||||
|
Horde.GLOOM_WORLD = { 0.42, 0.38, 0.56 }
|
||||||
|
Horde.GLOOM_INDOOR = { 0.55, 0.50, 0.68 }
|
||||||
|
Horde.GLOOM_SAT = 0.72
|
||||||
|
Horde.SHADOW_BOOST = 1.45 -- the moon presses harder than it should
|
||||||
|
|
||||||
|
-- ------- state
|
||||||
|
|
||||||
|
Horde.active = false -- every hook in this file gates on it
|
||||||
|
Horde.state = "idle" -- idle | intro | active | dying | gameover
|
||||||
|
Horde.session = nil
|
||||||
|
|
||||||
|
-- Whether the combat is live: mobs move, the gun fires, damage lands.
|
||||||
|
-- False during the intro beat, the death fade, the GAME OVER card -- and
|
||||||
|
-- while ANYTHING is on the stack above the overworld, which is what
|
||||||
|
-- stops the trigger from firing into a world the player has stopped
|
||||||
|
-- looking at while the exit prompt asks them a question.
|
||||||
|
function Horde.playing()
|
||||||
|
if not (Horde.active and Horde.state == "active") then return false end
|
||||||
|
local ok, live = pcall(function()
|
||||||
|
local G = require("src.core.Game")
|
||||||
|
local ow = G.overworld
|
||||||
|
return G.stack and ow and G.stack:top() == ow and not ow.transitioning
|
||||||
|
end)
|
||||||
|
return ok and live == true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether the mode owns the camera rung right now, which is the whole of
|
||||||
|
-- what "locked to first person" means: main.lua's cycleVoxel refuses
|
||||||
|
-- while this is true, and that one function is what the 3 key, the pad's
|
||||||
|
-- SELECT and the VR stick click all call.
|
||||||
|
function Horde.viewLocked()
|
||||||
|
return Horde.active
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether the free walk should skip its A (talk) and START (menu)
|
||||||
|
-- branches. Nobody stops to read a sign mid-firefight, and START has a
|
||||||
|
-- different job here (see askExit).
|
||||||
|
function Horde.suppressWorldInput()
|
||||||
|
return Horde.active
|
||||||
|
end
|
||||||
|
|
||||||
|
local function game()
|
||||||
|
local ok, G = pcall(require, "src.core.Game")
|
||||||
|
return ok and G or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function overworld(G)
|
||||||
|
G = G or game()
|
||||||
|
return G and G.overworld or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The way out, on demand. START -- the pad's, the keyboard's ESCAPE, the
|
||||||
|
-- touch overlay's -- and the VR left stick click all ask this, and it
|
||||||
|
-- asks the player. Nothing here ends the mode; the prompt does that
|
||||||
|
-- through Horde.finish if the answer is yes.
|
||||||
|
--
|
||||||
|
-- Refused while anything is already on top of the overworld, so the
|
||||||
|
-- question cannot stack on itself or arrive over the GAME OVER card.
|
||||||
|
--
|
||||||
|
-- BELOW the two helpers above, deliberately: a Lua local is only in
|
||||||
|
-- scope after its declaration, so a function written above them captures
|
||||||
|
-- the GLOBAL of that name instead -- which is nil, and only says so when
|
||||||
|
-- somebody presses the button.
|
||||||
|
function Horde.askExit(G)
|
||||||
|
G = G or game()
|
||||||
|
if not (Horde.active and Horde.state ~= "gameover") then return false end
|
||||||
|
local ow = overworld(G)
|
||||||
|
if not (G and G.stack and ow and G.stack:top() == ow) then return false end
|
||||||
|
if ow.transitioning then return false end
|
||||||
|
local pushed = false
|
||||||
|
pcall(function()
|
||||||
|
require("src.ui.Screens").push(G, "HordeExitPrompt")
|
||||||
|
pushed = true
|
||||||
|
end)
|
||||||
|
return pushed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the code
|
||||||
|
--
|
||||||
|
-- Advance on the expected button; on a wrong one, fall back to the
|
||||||
|
-- longest run already entered that is still a valid start of the code,
|
||||||
|
-- and try again from there. That fallback is why this is a table rather
|
||||||
|
-- than a counter: the code STARTS with a repeat, so a player who presses
|
||||||
|
-- Up three times has, on the third, still entered "Up Up" -- and a naive
|
||||||
|
-- "wrong button, back to the beginning" rule would throw one of them
|
||||||
|
-- away and refuse a code that was in fact typed correctly. (It is the
|
||||||
|
-- prefix function from Knuth-Morris-Pratt, over ten buttons.)
|
||||||
|
--
|
||||||
|
-- The timeout is in fixed steps, 60 to the second: a code is a deliberate
|
||||||
|
-- act, and a stray Up a minute ago should not be half of one.
|
||||||
|
|
||||||
|
local SEQUENCE = { "up", "up", "down", "down",
|
||||||
|
"left", "right", "left", "right", "b", "a" }
|
||||||
|
local IDLE_STEPS = 150 -- two and a half seconds between buttons
|
||||||
|
|
||||||
|
-- FALLBACK[n] = how much of the code is still entered after n matched
|
||||||
|
-- buttons and then a wrong one
|
||||||
|
local FALLBACK = { [0] = 0, [1] = 0 }
|
||||||
|
do
|
||||||
|
local k = 0
|
||||||
|
for i = 2, #SEQUENCE do
|
||||||
|
while k > 0 and SEQUENCE[k + 1] ~= SEQUENCE[i] do k = FALLBACK[k] end
|
||||||
|
if SEQUENCE[k + 1] == SEQUENCE[i] then k = k + 1 end
|
||||||
|
FALLBACK[i] = k
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local progress = 0
|
||||||
|
local sinceLast = 0
|
||||||
|
|
||||||
|
-- Named for the suite: how far into the code the detector has got.
|
||||||
|
function Horde._progress()
|
||||||
|
return progress
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetCode()
|
||||||
|
progress, sinceLast = 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Can the mode start from where the player is standing? The overworld has
|
||||||
|
-- to be the live state (not a menu, not a battle, not a transition wipe),
|
||||||
|
-- the 3D pass has to exist to put a camera inside, and the world has to be
|
||||||
|
-- free-roaming rather than mid-cutscene.
|
||||||
|
--
|
||||||
|
-- MID-STEP IS ALLOWED, and that is not an oversight. Six of the code's ten
|
||||||
|
-- buttons are directions, so entering it on a d-pad walks the player four
|
||||||
|
-- cells across the map -- and at the moment the closing A lands they are
|
||||||
|
-- very often still animating the last of those steps. Refusing a code for
|
||||||
|
-- being mid-step would refuse most of the codes anyone actually enters.
|
||||||
|
-- The snapshot records the cell the step began from, which is where the
|
||||||
|
-- restore puts them back.
|
||||||
|
function Horde.canStart(G)
|
||||||
|
G = G or game()
|
||||||
|
if not G or Horde.active then return false end
|
||||||
|
local ow = overworld(G)
|
||||||
|
if not (ow and ow.map and ow.player) then return false end
|
||||||
|
if not (G.stack and G.stack:top() == ow) then return false end
|
||||||
|
if ow.transitioning or ow.scripted or ow.engaging then return false end
|
||||||
|
if ow.player.inputLocked then return false end
|
||||||
|
if not Voxel3D.available() then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One fixed step of the detector, over the edges about to be promoted.
|
||||||
|
-- Separated from the hook so the suite can drive it with a plain list.
|
||||||
|
function Horde.feed(queue)
|
||||||
|
if Horde.active then
|
||||||
|
resetCode()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
sinceLast = sinceLast + 1
|
||||||
|
if progress > 0 and sinceLast > IDLE_STEPS then resetCode() end
|
||||||
|
local fired = false
|
||||||
|
for _, btn in ipairs(queue or {}) do
|
||||||
|
sinceLast = 0
|
||||||
|
while progress > 0 and SEQUENCE[progress + 1] ~= btn do
|
||||||
|
progress = FALLBACK[progress]
|
||||||
|
end
|
||||||
|
if SEQUENCE[progress + 1] == btn then
|
||||||
|
progress = progress + 1
|
||||||
|
if progress >= #SEQUENCE then
|
||||||
|
resetCode()
|
||||||
|
fired = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return fired
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the snapshot
|
||||||
|
--
|
||||||
|
-- Everything the mode changes, read back before it changes any of it.
|
||||||
|
-- Presentational settings included: the rung, the two engine FX levels the
|
||||||
|
-- rung clearing would zero, and the clock -- a player who was watching a
|
||||||
|
-- CYCLE sunset gets their sunset back.
|
||||||
|
|
||||||
|
local function snapshot(G)
|
||||||
|
local ow = overworld(G)
|
||||||
|
local p = ow.player
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local opts = G.save and G.save.options or {}
|
||||||
|
local snap = {
|
||||||
|
mapId = ow.map.id,
|
||||||
|
cellX = p.cellX, cellY = p.cellY,
|
||||||
|
px = p.px, py = p.py,
|
||||||
|
facing = p.facing,
|
||||||
|
viewLevel = Pipelines.level("voxel"),
|
||||||
|
tilt = opts.tilt or 0,
|
||||||
|
gbcfx = opts.gbcfx or 0,
|
||||||
|
fpYaw = FirstPerson.yaw,
|
||||||
|
fpPitch = FirstPerson.pitch,
|
||||||
|
dayIndex = DayNight.setting:read(),
|
||||||
|
dayClock = DayNight.clock,
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the gloom
|
||||||
|
--
|
||||||
|
-- Installed once and inert while the mode is off: each wrapper calls
|
||||||
|
-- through and returns the base answer untouched unless Horde.active.
|
||||||
|
|
||||||
|
local gloomInstalled = false
|
||||||
|
|
||||||
|
local function desaturate(r, g, b, keep)
|
||||||
|
local lum = 0.30 * r + 0.59 * g + 0.11 * b
|
||||||
|
return lum + (r - lum) * keep,
|
||||||
|
lum + (g - lum) * keep,
|
||||||
|
lum + (b - lum) * keep
|
||||||
|
end
|
||||||
|
|
||||||
|
local function installGloom()
|
||||||
|
if gloomInstalled then return end
|
||||||
|
gloomInstalled = true
|
||||||
|
|
||||||
|
-- The sky's bands. Sky.bands caches BY COLOUR VALUE, so darkening what
|
||||||
|
-- this returns rebuilds the band ramp on its own -- and puts it back the
|
||||||
|
-- same way when the mode ends.
|
||||||
|
do
|
||||||
|
local base = DayNight.palette
|
||||||
|
local cacheIn, cacheOut = nil, nil
|
||||||
|
DayNight.palette = function(t)
|
||||||
|
local pal = base(t)
|
||||||
|
if not Horde.active then return pal end
|
||||||
|
if cacheIn == pal then return cacheOut end
|
||||||
|
local k = Horde.GLOOM_SKY
|
||||||
|
local out = {}
|
||||||
|
for i, c in ipairs(pal) do
|
||||||
|
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||||
|
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||||
|
out[i] = { math.floor(r), math.floor(g), math.floor(b) }
|
||||||
|
end
|
||||||
|
cacheIn, cacheOut = pal, out
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The world multiply -- the voxel shader's tint uniform AND, through
|
||||||
|
-- DayTint, the flat 2D world. Indoors normally returns neutral white;
|
||||||
|
-- under the horde it does not, because a Pokemon Centre with the horde
|
||||||
|
-- in it should not look like a Pokemon Centre.
|
||||||
|
do
|
||||||
|
local base = DayNight.tint
|
||||||
|
local cacheIn, cacheOut, cacheOutdoor = nil, nil, nil
|
||||||
|
DayNight.tint = function(outdoor, t)
|
||||||
|
local c = base(outdoor, t)
|
||||||
|
if not Horde.active then return c end
|
||||||
|
if cacheIn == c and cacheOutdoor == outdoor then return cacheOut end
|
||||||
|
local k = outdoor and Horde.GLOOM_WORLD or Horde.GLOOM_INDOOR
|
||||||
|
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||||
|
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||||
|
cacheIn, cacheOutdoor, cacheOut = c, outdoor, { r, g, b }
|
||||||
|
return cacheOut
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- and the shadows press harder: applyRig writes SHADOW_ALPHA from the
|
||||||
|
-- hour, so the boost goes on after it has had its say
|
||||||
|
do
|
||||||
|
local base = DayNight.applyRig
|
||||||
|
DayNight.applyRig = function(outdoor)
|
||||||
|
local t = base(outdoor)
|
||||||
|
if Horde.active then
|
||||||
|
Voxel3D.SHADOW_ALPHA = math.min(0.75,
|
||||||
|
(Voxel3D.SHADOW_ALPHA or 0) * Horde.SHADOW_BOOST)
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- starting
|
||||||
|
|
||||||
|
-- The banner over the world: text, and how long it holds before fading.
|
||||||
|
function Horde.banner(text, hold)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
s.bannerText = text
|
||||||
|
s.bannerT = 0
|
||||||
|
s.bannerHold = hold or 2.2
|
||||||
|
end
|
||||||
|
|
||||||
|
function Horde.begin(G)
|
||||||
|
G = G or game()
|
||||||
|
if not Horde.canStart(G) then return false end
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local mobs, gun = parts()
|
||||||
|
|
||||||
|
local snap = snapshot(G)
|
||||||
|
Horde.session = {
|
||||||
|
hp = Horde.MAX_HP, maxHp = Horde.MAX_HP,
|
||||||
|
score = 0, wave = 0, kills = 0,
|
||||||
|
t = 0, introT = Horde.INTRO_TIME, dyingT = 0,
|
||||||
|
damageFlash = 0, hitMarker = 0, hurtCooldown = 0,
|
||||||
|
bannerText = nil, bannerT = 0, bannerHold = 0,
|
||||||
|
snapshot = snap,
|
||||||
|
spawned = {}, -- mapId -> { [objIndex] = true }, for the scrub
|
||||||
|
mobs = {},
|
||||||
|
waveRemaining = 0, waveGap = 0, spawnGap = 0, followQueue = 0,
|
||||||
|
startedAt = os and os.time and os.time() or 0,
|
||||||
|
}
|
||||||
|
Horde.active = true
|
||||||
|
Horde.state = "intro"
|
||||||
|
|
||||||
|
-- the rung, forced and then held: FP_LEVEL is the one rung with a camera
|
||||||
|
-- inside the world, and cycleVoxel refuses to leave it while active
|
||||||
|
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||||
|
Pipelines.syncOptions(G.save.options)
|
||||||
|
G.save.options.tilt, G.save.options.gbcfx = 0, 0
|
||||||
|
pcall(function() require("src.render.Tilt").setLevel(0) end)
|
||||||
|
pcall(function() require("src.render.GBCFX").setLevel(0) end)
|
||||||
|
pcall(G.writeOptions, G)
|
||||||
|
|
||||||
|
-- night, pinned; the gloom wrappers do the rest on top of it
|
||||||
|
local nightIndex = 3 -- DayNight.setting values: sync/day/NIGHT/...
|
||||||
|
for i, v in ipairs(DayNight.setting.values) do
|
||||||
|
if v == "night" then nightIndex = i end
|
||||||
|
end
|
||||||
|
DayNight.setting:setIndex(nightIndex, G)
|
||||||
|
|
||||||
|
pcall(function()
|
||||||
|
require("src.core.Music").play(G.data, Horde.SONG, true,
|
||||||
|
{ reason = "horde" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
gun.reset()
|
||||||
|
mobs.begin(G)
|
||||||
|
Horde.banner("A DARKNESS APPROACHES", 2.6)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- damage and score
|
||||||
|
|
||||||
|
function Horde.addScore(n)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
s.score = s.score + (n or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A mob reached the player. Returns true when the hit landed (it is on a
|
||||||
|
-- cooldown, so a crowd of six does not delete the player in one frame).
|
||||||
|
function Horde.damage(n)
|
||||||
|
local s = Horde.session
|
||||||
|
if not (s and Horde.playing()) then return false end
|
||||||
|
if s.hurtCooldown > 0 then return false end
|
||||||
|
s.hurtCooldown = 0.55
|
||||||
|
s.hp = math.max(0, s.hp - (n or Horde.CONTACT_DAMAGE))
|
||||||
|
s.damageFlash = 1
|
||||||
|
HordeSfx.play(HordeSfx.HURT)
|
||||||
|
if s.hp <= 0 then
|
||||||
|
Horde.state = "dying"
|
||||||
|
s.dyingT = Horde.DYING_TIME
|
||||||
|
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the ending
|
||||||
|
|
||||||
|
local function pushGameOver(G)
|
||||||
|
Horde.state = "gameover"
|
||||||
|
local s = Horde.session
|
||||||
|
local best = 0
|
||||||
|
pcall(function() best = V.mod.save:get("hordeBest", 0) or 0 end)
|
||||||
|
if s.score > best then
|
||||||
|
best = s.score
|
||||||
|
pcall(function() V.mod.save:set("hordeBest", best) end)
|
||||||
|
end
|
||||||
|
s.best = best
|
||||||
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
|
pcall(function()
|
||||||
|
require("src.ui.Screens").push(G, "HordeGameOver")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Put everything back. Called from the GAME OVER card's A press.
|
||||||
|
--
|
||||||
|
-- Order matters: active goes false FIRST, so the music hook, the gloom
|
||||||
|
-- wrappers and the mob spawner have all stood down before anything is
|
||||||
|
-- restored under them. The warp home is taken even when the player never
|
||||||
|
-- left the map they started on -- setMap rebuilds the cast from the map
|
||||||
|
-- record, which is what puts every NPC the horde ate back on its feet.
|
||||||
|
function Horde.finish(G)
|
||||||
|
G = G or game()
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return false end
|
||||||
|
local mobs = parts()
|
||||||
|
local snap = s.snapshot or {}
|
||||||
|
|
||||||
|
Horde.active = false
|
||||||
|
Horde.state = "idle"
|
||||||
|
resetCode()
|
||||||
|
|
||||||
|
mobs.cleanup(G)
|
||||||
|
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||||
|
|
||||||
|
-- the clock, back to the hour and the setting the player kept
|
||||||
|
if snap.dayIndex then DayNight.setting:setIndex(snap.dayIndex, G) end
|
||||||
|
if snap.dayClock then DayNight.clock = snap.dayClock end
|
||||||
|
|
||||||
|
-- the rung and the two FX levels the rung clearing zeroed
|
||||||
|
pcall(function()
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
Pipelines.setLevel("voxel", snap.viewLevel or 0)
|
||||||
|
Pipelines.syncOptions(G.save.options)
|
||||||
|
G.save.options.tilt = snap.tilt or 0
|
||||||
|
G.save.options.gbcfx = snap.gbcfx or 0
|
||||||
|
require("src.render.Tilt").setLevel(snap.tilt or 0)
|
||||||
|
require("src.render.GBCFX").setLevel(snap.gbcfx or 0)
|
||||||
|
G:writeOptions()
|
||||||
|
end)
|
||||||
|
|
||||||
|
if snap.fpYaw then FirstPerson.yaw = snap.fpYaw end
|
||||||
|
if snap.fpPitch then FirstPerson.pitch = snap.fpPitch end
|
||||||
|
|
||||||
|
Horde.session = nil
|
||||||
|
|
||||||
|
-- home, through the engine's own warp: a fade, a setMap, and the map's
|
||||||
|
-- own music coming back up on the other side (the hook that was forcing
|
||||||
|
-- Lavender is inert now)
|
||||||
|
local ow = overworld(G)
|
||||||
|
if ow and snap.mapId then
|
||||||
|
pcall(function()
|
||||||
|
ow:startWarpTo(snap.mapId, snap.cellX, snap.cellY, snap.facing or "down",
|
||||||
|
function()
|
||||||
|
-- the pixel position and the facing, restated on
|
||||||
|
-- the far side of the fade. setMap already placed
|
||||||
|
-- both, but the free walk owns them while the rung
|
||||||
|
-- is still easing out of the head, and the head was
|
||||||
|
-- looking wherever the last shot was aimed
|
||||||
|
local p = overworld(G) and overworld(G).player
|
||||||
|
if not p then return end
|
||||||
|
if snap.px then p.px, p.py = snap.px, snap.py end
|
||||||
|
if snap.facing then p.facing = snap.facing end
|
||||||
|
end,
|
||||||
|
{ via = "warp" })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the tick
|
||||||
|
--
|
||||||
|
-- Rides the voxel pipeline's update hook, which Game:update calls every
|
||||||
|
-- frame whatever the level and whatever is on the stack -- so the mode
|
||||||
|
-- keeps thinking through a warp's transition wipe and under the GAME OVER
|
||||||
|
-- card, which is exactly what a mode that owns the whole screen needs.
|
||||||
|
|
||||||
|
function Horde.update(dt)
|
||||||
|
if not Horde.active then return end
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then
|
||||||
|
Horde.active = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
dt = math.min(dt or 0, 0.1) -- a hitch must not teleport the wave
|
||||||
|
local G = game()
|
||||||
|
local mobs, gun, hud = parts()
|
||||||
|
|
||||||
|
s.t = s.t + dt
|
||||||
|
s.damageFlash = math.max(0, s.damageFlash - dt * 2.2)
|
||||||
|
s.hitMarker = math.max(0, s.hitMarker - dt * 4)
|
||||||
|
s.hurtCooldown = math.max(0, s.hurtCooldown - dt)
|
||||||
|
if s.bannerText then
|
||||||
|
s.bannerT = s.bannerT + dt
|
||||||
|
if s.bannerT > s.bannerHold + 1.1 then s.bannerText = nil end
|
||||||
|
end
|
||||||
|
hud.update(dt)
|
||||||
|
|
||||||
|
if Horde.state == "intro" then
|
||||||
|
s.introT = s.introT - dt
|
||||||
|
if s.introT <= 0 then
|
||||||
|
Horde.state = "active"
|
||||||
|
mobs.nextWave(G)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if Horde.state == "dying" then
|
||||||
|
s.dyingT = s.dyingT - dt
|
||||||
|
mobs.update(dt, G) -- the crowd keeps coming while you fall
|
||||||
|
if s.dyingT <= 0 then pushGameOver(G) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if Horde.state ~= "active" then return end
|
||||||
|
|
||||||
|
-- the world only ticks while the overworld is actually the live state:
|
||||||
|
-- during a warp's wipe there is no map under the mobs to walk on
|
||||||
|
local ow = overworld(G)
|
||||||
|
local live = G and G.stack and ow and G.stack:top() == ow
|
||||||
|
and not ow.transitioning
|
||||||
|
gun.update(dt, live)
|
||||||
|
if live then mobs.update(dt, G) end
|
||||||
|
|
||||||
|
-- the siren the game already owns, for the last third of the health bar
|
||||||
|
local low = s.hp <= s.maxHp * 0.3
|
||||||
|
if low ~= s.alarmOn then
|
||||||
|
s.alarmOn = low
|
||||||
|
pcall(function()
|
||||||
|
local Sound = require("src.core.Sound")
|
||||||
|
if low then Sound.startLoop(G.data, "Low_Health_Alarm")
|
||||||
|
else Sound.stopLoop("Low_Health_Alarm") end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the seams
|
||||||
|
--
|
||||||
|
-- Every engine and mod hook the mode needs, installed once. main.lua
|
||||||
|
-- calls this AFTER FreeMove.install and the SELECT wrap, so the
|
||||||
|
-- handleInput wrap this adds sits outside both of theirs.
|
||||||
|
|
||||||
|
local installed = false
|
||||||
|
|
||||||
|
function Horde.install()
|
||||||
|
if installed then return end
|
||||||
|
installed = true
|
||||||
|
local mod = V.mod
|
||||||
|
|
||||||
|
installGloom()
|
||||||
|
|
||||||
|
-- THE CODE. `input.step` runs once per fixed step, immediately before
|
||||||
|
-- Input:step promotes the queue into this step's edges -- so pressQueue
|
||||||
|
-- is exactly "the buttons that were pressed since last time", in order,
|
||||||
|
-- from every device at once. Read, never consumed: the game still gets
|
||||||
|
-- every one of them.
|
||||||
|
mod.hooks:wrap("input.step", function(next, G, dt)
|
||||||
|
local inp = G and G.input
|
||||||
|
if inp and inp.pressQueue and Horde.feed(inp.pressQueue) then
|
||||||
|
pcall(Horde.begin, G)
|
||||||
|
elseif Horde.playing() and inp and inp.pressQueue then
|
||||||
|
-- B is a trigger while the horde is up (the pad's B, the keyboard's,
|
||||||
|
-- the touch overlay's). Read here rather than in the frame tick
|
||||||
|
-- because THIS is the boundary that sees each press exactly once.
|
||||||
|
for _, btn in ipairs(inp.pressQueue) do
|
||||||
|
if btn == "b" then
|
||||||
|
local _, gun = parts()
|
||||||
|
gun.fire()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return next(G, dt)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Lavender, and it stays Lavender. Every song choice in the engine goes
|
||||||
|
-- through this hook, so a door into a building cannot change the record.
|
||||||
|
mod.hooks:wrap("music.select", function(next, chosen, ctx)
|
||||||
|
if Horde.active and Horde.state ~= "gameover" then
|
||||||
|
return next(Horde.SONG, ctx)
|
||||||
|
end
|
||||||
|
return next(chosen, ctx)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- no wild encounters: returning nil from this hook suppresses the roll
|
||||||
|
-- outright, which is the documented way to do it
|
||||||
|
mod.hooks:wrap("encounter.roll", function(next, encDef, ctx)
|
||||||
|
if Horde.active then return nil end
|
||||||
|
return next(encDef, ctx)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- and no trainer walking up to talk. Wrapped rather than set through
|
||||||
|
-- self.engaging, which would also freeze the player's own input.
|
||||||
|
do
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
if not OverworldState.dramaticShapeHordeSight then
|
||||||
|
local inner = OverworldState.checkTrainerSight
|
||||||
|
function OverworldState:checkTrainerSight(...)
|
||||||
|
if Horde.active then return end
|
||||||
|
return inner(self, ...)
|
||||||
|
end
|
||||||
|
OverworldState.dramaticShapeHordeSight = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- THE BUTTONS THE WORLD MAY NOT HAVE. A, START, SELECT and B are the
|
||||||
|
-- mode's, and this wrap is where they are taken -- the OUTERMOST wrap on
|
||||||
|
-- handleInput, installed after FreeMove's and after the SELECT hook, so
|
||||||
|
-- the edges are gone before either of them looks.
|
||||||
|
--
|
||||||
|
-- It has to be here rather than inside the free walk, because the free
|
||||||
|
-- walk is not always the one reading: the rung is forced to 1ST at the
|
||||||
|
-- moment the code completes, but the camera takes a few frames to blend
|
||||||
|
-- into the head, and until it does the GRID walk still owns the frame.
|
||||||
|
-- That is not a corner case -- it is the very first frame of every run,
|
||||||
|
-- and the code's own closing A was landing in it and opening a dialogue
|
||||||
|
-- with whoever the player happened to be standing next to.
|
||||||
|
--
|
||||||
|
-- The EDGE is cleared, not the hold: pressed[] is rebuilt from scratch
|
||||||
|
-- every fixed step, so this reaches exactly this step's presses and
|
||||||
|
-- nothing downstream of it can revive one.
|
||||||
|
do
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
if not OverworldState.dramaticShapeHordeInput then
|
||||||
|
local inner = OverworldState.handleInput
|
||||||
|
function OverworldState:handleInput(...)
|
||||||
|
if Horde.active then
|
||||||
|
local G = game()
|
||||||
|
local inp = G and G.input
|
||||||
|
if inp and inp.pressed then
|
||||||
|
-- START is the way out, and it is asked rather than taken:
|
||||||
|
-- read here, BEFORE the edge is cleared, so the engine's own
|
||||||
|
-- START menu never sees it
|
||||||
|
if inp.pressed.start then Horde.askExit(G) end
|
||||||
|
inp.pressed.a = nil -- no talking
|
||||||
|
inp.pressed.b = nil -- the trigger, already read
|
||||||
|
inp.pressed.start = nil -- and no start menu
|
||||||
|
inp.pressed.select = nil -- no changing the view
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return inner(self, ...)
|
||||||
|
end
|
||||||
|
OverworldState.dramaticShapeHordeInput = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the crowd follows the player through the door: a warp lands a new map
|
||||||
|
-- with none of the old one's actors on it, so the roster is re-seeded on
|
||||||
|
-- the far side (lib/HordeMobs)
|
||||||
|
mod.events:on("map.entered", function(payload)
|
||||||
|
if not Horde.active then return end
|
||||||
|
local mobs = parts()
|
||||||
|
pcall(mobs.onMapEntered, payload)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Horde
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- HORDE MODE: the way out.
|
||||||
|
--
|
||||||
|
-- START (the pad's, the keyboard's ESCAPE, the touch overlay's) and the
|
||||||
|
-- VR left stick click all land here: a plain yes/no over the frozen
|
||||||
|
-- world, asking whether to leave. YES hands over to Horde.finish, which
|
||||||
|
-- is the same restore the GAME OVER card runs -- the map, the cell, the
|
||||||
|
-- facing, the camera rung, the hour, the music and every NPC put back
|
||||||
|
-- exactly as they were. NO drops the player straight back into the
|
||||||
|
-- firefight.
|
||||||
|
--
|
||||||
|
-- Pushing a state is what pauses the mode, and it is the only thing that
|
||||||
|
-- can: horde mode rides the pipeline's update hook rather than the state
|
||||||
|
-- stack precisely so that nothing on the stack stops it, but the combat
|
||||||
|
-- inside Horde.update is gated on the overworld actually being the live
|
||||||
|
-- state, so this prompt freezes the crowd and the gun for as long as it
|
||||||
|
-- is up. That is the correct behaviour for a confirmation and it is why
|
||||||
|
-- "no pausing" does not extend to this one.
|
||||||
|
--
|
||||||
|
-- Drawn the way the game draws a yes/no: a white bordered box with black
|
||||||
|
-- text and the filled arrow beside the row (see Theme.choiceBox, which
|
||||||
|
-- is where the original's own YES_NO_MENU sits). Black on white because
|
||||||
|
-- that is what the font IS -- the sheets are black glyphs on transparent
|
||||||
|
-- and no colour can lighten one.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
|
||||||
|
local HordeExitPrompt = {}
|
||||||
|
HordeExitPrompt.__index = HordeExitPrompt
|
||||||
|
|
||||||
|
-- the question's box, and the choice box under it, in 8px tiles
|
||||||
|
local ASK = { tx = 1, ty = 6, tw = 18, th = 4 }
|
||||||
|
local PICK = { tx = 13, ty = 10, tw = 6, th = 6 }
|
||||||
|
|
||||||
|
function HordeExitPrompt.new(game)
|
||||||
|
local self = setmetatable({}, HordeExitPrompt)
|
||||||
|
self.game = game
|
||||||
|
-- NOT opaque: the horde is still standing out there behind this, which
|
||||||
|
-- is most of what makes the question feel like a decision
|
||||||
|
self.isOpaque = false
|
||||||
|
self.index = 2 -- NO, the way every dangerous prompt starts
|
||||||
|
self.done = false
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sfx(game, name)
|
||||||
|
pcall(function()
|
||||||
|
require("src.core.Sound").play(game.data, name)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeExitPrompt:update()
|
||||||
|
if self.done then return end
|
||||||
|
local input = self.game and self.game.input
|
||||||
|
if not input then return end
|
||||||
|
|
||||||
|
if input:wasPressed("up") or input:wasPressed("down") then
|
||||||
|
self.index = self.index == 1 and 2 or 1
|
||||||
|
elseif input:wasPressed("a") then
|
||||||
|
self.done = true
|
||||||
|
sfx(self.game, "Press_AB")
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.index == 1 then pcall(Horde.finish, self.game) end
|
||||||
|
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||||
|
-- B and START both mean "no": the button that opened this closes it,
|
||||||
|
-- which is the one thing a player who opened it by accident will try
|
||||||
|
self.done = true
|
||||||
|
sfx(self.game, "Press_AB")
|
||||||
|
self.game.stack:pop()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeExitPrompt:draw()
|
||||||
|
local ok, Font = pcall(require, "src.render.Font")
|
||||||
|
if not ok then return end
|
||||||
|
local okT, Theme = pcall(require, "src.ui.Theme")
|
||||||
|
|
||||||
|
Font.drawBox(ASK.tx, ASK.ty, ASK.tw, ASK.th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("EXIT MINI GAME?", (ASK.tx + 2) * 8, (ASK.ty + 2) * 8)
|
||||||
|
|
||||||
|
Font.drawBox(PICK.tx, PICK.ty, PICK.tw, PICK.th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("YES", (PICK.tx + 2) * 8, (PICK.ty + 2) * 8)
|
||||||
|
Font.draw("NO", (PICK.tx + 2) * 8, (PICK.ty + 4) * 8)
|
||||||
|
local cursor = okT and Theme.cursor or 0xED
|
||||||
|
Font.drawCode(cursor, (PICK.tx + 1) * 8,
|
||||||
|
(PICK.ty + 2 + (self.index - 1) * 2) * 8)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HordeExitPrompt
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
-- HORDE MODE: the card at the end.
|
||||||
|
--
|
||||||
|
-- A stack state, unlike the mode itself -- and for the opposite reason.
|
||||||
|
-- Horde mode cannot be a pushed state because pushing one stops the
|
||||||
|
-- overworld ticking and the player could not walk; the GAME OVER card
|
||||||
|
-- WANTS exactly that. Pushed, it freezes the world underneath, takes the
|
||||||
|
-- buttons, and stands there until A.
|
||||||
|
--
|
||||||
|
-- It draws in the engine's own 160x144 UI canvas with the game's own
|
||||||
|
-- font, which is what makes it work in VR for free: with a headset live
|
||||||
|
-- and something other than the overworld on top of the stack, lib/VR
|
||||||
|
-- already puts the flat screen on the floating panel (or on the Pokedex
|
||||||
|
-- in the player's left hand). A card drawn the way the game draws cards
|
||||||
|
-- arrives there with no VR code at all.
|
||||||
|
--
|
||||||
|
-- A pops it and hands over to Horde.finish, which is what puts the world
|
||||||
|
-- back: the map, the cell, the facing, the camera rung, the hour, the
|
||||||
|
-- music, and every NPC the horde had turned into a mob.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
|
||||||
|
local W, H = 160, 144
|
||||||
|
|
||||||
|
local HordeGameOver = {}
|
||||||
|
HordeGameOver.__index = HordeGameOver
|
||||||
|
|
||||||
|
function HordeGameOver.new(game)
|
||||||
|
local self = setmetatable({}, HordeGameOver)
|
||||||
|
self.game = game
|
||||||
|
self.isOpaque = true
|
||||||
|
self.t = 0
|
||||||
|
-- the session is read ONCE, here: Horde.finish clears it, and this card
|
||||||
|
-- outlives that by a frame or two while the warp home fades
|
||||||
|
local s = Horde.session or {}
|
||||||
|
self.score = math.floor(s.score or 0)
|
||||||
|
self.best = math.floor(s.best or 0)
|
||||||
|
self.wave = math.max(1, s.wave or 1)
|
||||||
|
self.kills = s.kills or 0
|
||||||
|
self.done = false
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGameOver:update(dt)
|
||||||
|
self.t = self.t + (dt or 0)
|
||||||
|
if self.done then return end
|
||||||
|
-- a beat of dead air before the prompt takes input, so the button that
|
||||||
|
-- was being mashed at the moment of death does not dismiss the card
|
||||||
|
if self.t < 0.6 then return end
|
||||||
|
local input = self.game and self.game.input
|
||||||
|
if input and input:wasPressed("a") then
|
||||||
|
self.done = true
|
||||||
|
pcall(function()
|
||||||
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
|
end)
|
||||||
|
self.game.stack:pop()
|
||||||
|
pcall(Horde.finish, self.game)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- THE CARD IS DRAWN THE WAY THE GAME DRAWS CARDS: a bordered white box
|
||||||
|
-- with black text in it (Font.drawBox then setColor(0,0,0)), exactly as
|
||||||
|
-- HallOfFame and every menu do. That is not decoration -- the UI canvas
|
||||||
|
-- is a FOUR-SHADE Game Boy screen, and an arbitrary colour drawn into it
|
||||||
|
-- has nowhere to land. A first cut of this card painted a dark red on
|
||||||
|
-- near-black and composited as a rectangle of pure black, with the score
|
||||||
|
-- in it and invisible.
|
||||||
|
local function centred(Font, str, y, scale)
|
||||||
|
scale = scale or 1
|
||||||
|
local w = Font.width(str) * scale
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(math.floor((W - w) / 2), y)
|
||||||
|
love.graphics.scale(scale, scale)
|
||||||
|
Font.draw(str, 0, 0)
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGameOver:draw()
|
||||||
|
local ok, Font = pcall(require, "src.render.Font")
|
||||||
|
if not ok then return end
|
||||||
|
|
||||||
|
-- the whole screen as one box: isOpaque keeps the stack from drawing
|
||||||
|
-- the world under it, but the canvas still holds whatever was there
|
||||||
|
Font.drawBox(0, 0, 20, 18)
|
||||||
|
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
centred(Font, "GAME OVER", 3 * 8, 2)
|
||||||
|
|
||||||
|
centred(Font, ("SCORE %d"):format(self.score), 8 * 8)
|
||||||
|
centred(Font, ("WAVE %d"):format(self.wave), 10 * 8)
|
||||||
|
centred(Font, ("KILLS %d"):format(self.kills), 11 * 8)
|
||||||
|
|
||||||
|
if self.best > 0 then
|
||||||
|
centred(Font, (self.score >= self.best) and "NEW BEST!"
|
||||||
|
or ("BEST %d"):format(self.best), 13 * 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the prompt blinks the way every "press a button" in this game blinks
|
||||||
|
if self.t > 0.6 and (self.t % 1.0) < 0.62 then
|
||||||
|
centred(Font, "PRESS A", 15 * 8)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HordeGameOver
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
-- HORDE MODE: the handgun.
|
||||||
|
--
|
||||||
|
-- A voxel model in the player's right hand, authored here in METRES the
|
||||||
|
-- way lib/Pokedex authors the device in the left one -- because the VR
|
||||||
|
-- mapping's scale is what turns metres into world pixels, a mesh built
|
||||||
|
-- this way is the right size in the hand at every scale the mod has, and
|
||||||
|
-- the same mesh serves the flat screen's view model.
|
||||||
|
--
|
||||||
|
-- IN VR the gun rides the tracked right hand through VRRig.propMatrix,
|
||||||
|
-- pointed by the runtime's AIM pose where one exists (the pose a runtime
|
||||||
|
-- defines as "where the user is pointing") and by the grip pose where it
|
||||||
|
-- does not. You aim it by pointing it. The iron sights are real geometry,
|
||||||
|
-- and lining them up is how you shoot accurately, because the shot is
|
||||||
|
-- traced down the model's own barrel axis.
|
||||||
|
--
|
||||||
|
-- ON THE FLAT SCREEN there is no hand to track, so the gun is carried by
|
||||||
|
-- the camera: a model matrix built from the first-person eye and its yaw
|
||||||
|
-- and pitch, with the gun hanging at the hip until the player aims. AIM
|
||||||
|
-- DOWN SIGHTS slides it to the centre of the screen with the sight line
|
||||||
|
-- ON the eye axis -- the model is authored with its rear notch at the
|
||||||
|
-- origin precisely so that offset is (0, 0, forward) -- and narrows the
|
||||||
|
-- field of view, which is the whole of what aiming does here.
|
||||||
|
--
|
||||||
|
-- THE SHOT IS A RAY, traced the same way in both modes: march it in world
|
||||||
|
-- pixels, let terrain height stop it (a wall is a tall cell, so a cell
|
||||||
|
-- whose ground is above the ray's height is a wall the bullet hits), and
|
||||||
|
-- test every live mob against it as a standing cylinder. Nearest wins,
|
||||||
|
-- and a hit above the shoulder line counts double.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Mat4 = V.require("Mat4")
|
||||||
|
local Voxel3D = V.require("Voxel3D")
|
||||||
|
local VRRig = V.require("VRRig")
|
||||||
|
local FirstPerson = V.require("FirstPerson")
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
local HordeSfx = V.require("HordeSfx")
|
||||||
|
|
||||||
|
local HordeGun = {}
|
||||||
|
|
||||||
|
-- ------- tuning
|
||||||
|
|
||||||
|
HordeGun.MAG = 8
|
||||||
|
HordeGun.RELOAD_TIME = 1.5
|
||||||
|
HordeGun.FIRE_COOLDOWN = 0.17 -- semi-auto, and it fits the reload clicks
|
||||||
|
HordeGun.RANGE = 220 -- world pixels: about fourteen cells
|
||||||
|
HordeGun.HIT_RADIUS = 6 -- a person is about twelve pixels wide
|
||||||
|
HordeGun.ADS_TIME = 0.13
|
||||||
|
HordeGun.ADS_FOV = math.rad(40)
|
||||||
|
|
||||||
|
-- Where the gun sits relative to the EYE, in metres, hip and aimed. The
|
||||||
|
-- model's own origin is its rear sight notch, so the aimed offset is a
|
||||||
|
-- pure push forward: nothing to line up, it is already lined up.
|
||||||
|
HordeGun.HIP = { -0.115, -0.125, 0.30 }
|
||||||
|
HordeGun.ADS = { 0, -0.002, 0.34 }
|
||||||
|
|
||||||
|
-- Where it sits relative to the tracked hand, in METRES and in the POSE's
|
||||||
|
-- own axes -- so with the barrel pointed away from the player (see below)
|
||||||
|
-- -Z is forward, and this nudges the gun a little down and forward of the
|
||||||
|
-- pose origin so the hand is behind it rather than inside it.
|
||||||
|
HordeGun.HAND_OFFSET = { 0, -0.012, -0.02 }
|
||||||
|
|
||||||
|
-- THE BARREL, AND WHICH WAY IS FORWARD.
|
||||||
|
--
|
||||||
|
-- OpenXR's AIM pose -- the one this rides where the runtime offers it --
|
||||||
|
-- is defined with its **-Z axis pointing the way the user is aiming**.
|
||||||
|
-- The model below is authored with its barrel along **+Z**, because that
|
||||||
|
-- is what the flat screen's view model wants (Ry(yaw)*Rx(pitch) carries
|
||||||
|
-- +Z onto the look direction). Half a turn about Y is what reconciles
|
||||||
|
-- them, and it is the whole of the attachment.
|
||||||
|
--
|
||||||
|
-- Getting this wrong does not read as "slightly off": the first cut
|
||||||
|
-- copied the Pokedex's quarter-turn about X, which lays a flat slab along
|
||||||
|
-- the controller's body and is exactly right for a slab -- on a gun it
|
||||||
|
-- pointed the muzzle at the player's own face.
|
||||||
|
HordeGun.HAND_YAW = math.pi
|
||||||
|
|
||||||
|
-- AND A PITCH, because a hand is not a tripod. A controller held the way
|
||||||
|
-- you hold a pistol -- fist closed, wrist cocked -- has its own aim axis
|
||||||
|
-- running up and forward out of the top of your fist, well above the line
|
||||||
|
-- your hand FEELS like it is pointing along. A model laid flat on that
|
||||||
|
-- axis reads as a gun held by somebody with a broken wrist.
|
||||||
|
--
|
||||||
|
-- So the gun tips its muzzle down 45 degrees off the pose, which puts the
|
||||||
|
-- barrel back on the line the grip implies. The shot follows: the ray is
|
||||||
|
-- read off the finished matrix's own +Z column (see place), so it comes
|
||||||
|
-- out of the barrel as drawn rather than off the pose it was hung on --
|
||||||
|
-- point the gun, hit the thing.
|
||||||
|
HordeGun.HAND_PITCH = math.rad(45)
|
||||||
|
|
||||||
|
-- ------- the model
|
||||||
|
--
|
||||||
|
-- One voxel is 8mm, so the pistol below comes out about 18cm long -- a
|
||||||
|
-- compact service automatic. Authored around the REAR SIGHT NOTCH at the
|
||||||
|
-- origin, barrel down +Z, up +Y. (+X is the viewer's LEFT: the world runs
|
||||||
|
-- +X east and +Z south, so a body facing +Z has its right hand toward
|
||||||
|
-- -X, which is why the hip offset's x is negative.)
|
||||||
|
|
||||||
|
local VOX = 0.008
|
||||||
|
|
||||||
|
local COLORS = {
|
||||||
|
{ 60, 62, 72 }, -- 1 slide
|
||||||
|
{ 30, 31, 38 }, -- 2 frame / shadowed
|
||||||
|
{ 46, 40, 40 }, -- 3 grip
|
||||||
|
{ 104, 108, 122 }, -- 4 highlight
|
||||||
|
{ 248, 240, 176 }, -- 5 sight dot
|
||||||
|
{ 18, 18, 22 }, -- 6 bore
|
||||||
|
{ 132, 136, 148 }, -- 7 trigger
|
||||||
|
{ 255, 246, 196 }, -- 8 flash core
|
||||||
|
{ 255, 168, 56 }, -- 9 flash edge
|
||||||
|
}
|
||||||
|
|
||||||
|
local paletteTex, bodyMesh, flashMesh = nil, nil, nil
|
||||||
|
|
||||||
|
local function palette()
|
||||||
|
if paletteTex then return paletteTex end
|
||||||
|
if not (love.image and love.image.newImageData
|
||||||
|
and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
|
||||||
|
if not (ok and data) then return nil end
|
||||||
|
for i, c in ipairs(COLORS) do
|
||||||
|
pcall(data.setPixel, data, i - 1, 0,
|
||||||
|
c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||||
|
end
|
||||||
|
local built, img = pcall(love.graphics.newImage, data)
|
||||||
|
if not built then return nil end
|
||||||
|
pcall(img.setFilter, img, "nearest", "nearest")
|
||||||
|
paletteTex = img
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
|
-- one solid box, in voxels, straight into the shared vertex format
|
||||||
|
local function box(verts, indices, x, y, z, w, h, d, color)
|
||||||
|
local u = (color - 0.5) / #COLORS
|
||||||
|
local ox, oy, oz = x * VOX, y * VOX, z * VOX
|
||||||
|
local sx, sy, sz = w * VOX, h * VOX, d * VOX
|
||||||
|
for face = 1, 6 do
|
||||||
|
local corners = Voxel3D.FACE_CORNERS[face]
|
||||||
|
local shade = Voxel3D.FACE_SHADE[face]
|
||||||
|
local n = #verts / 4
|
||||||
|
for _, c in ipairs(corners) do
|
||||||
|
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
|
||||||
|
u, 0.5, shade }
|
||||||
|
end
|
||||||
|
Voxel3D.pushQuad(indices, n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildBody()
|
||||||
|
if bodyMesh then return bodyMesh end
|
||||||
|
local v, i = {}, {}
|
||||||
|
-- slide, and the bore's dark eye at the end of it
|
||||||
|
box(v, i, -2, -5, -1, 4, 4, 18, 1)
|
||||||
|
box(v, i, -2, -2, -1, 4, 0.6, 18, 4) -- the light along the top edge
|
||||||
|
box(v, i, -1, -4, 16.6, 2, 2, 0.6, 6)
|
||||||
|
-- frame under the slide, and the dust cover forward of the guard
|
||||||
|
box(v, i, -1.8, -8, 0.5, 3.6, 3.2, 12, 2)
|
||||||
|
-- the grip, three blocks stepping back: a raked butt without a hull
|
||||||
|
box(v, i, -1.8, -11, -1.2, 3.6, 3.2, 5, 3)
|
||||||
|
box(v, i, -1.8, -14, -2.6, 3.6, 3.2, 5, 3)
|
||||||
|
box(v, i, -1.8, -16.8, -3.8, 3.6, 3, 5, 2)
|
||||||
|
-- trigger guard: the bar under, the post in front
|
||||||
|
box(v, i, -1.4, -11.4, 3.6, 2.8, 1, 4.4, 2)
|
||||||
|
box(v, i, -1.4, -11.4, 7.4, 2.8, 3.4, 1, 2)
|
||||||
|
box(v, i, -0.9, -10.8, 4.6, 1.8, 2, 1, 7) -- the trigger itself
|
||||||
|
-- IRON SIGHTS. Two rear posts with a notch between them at the origin,
|
||||||
|
-- one front post at the muzzle: look through the gap, put the front
|
||||||
|
-- post's dot in it, and the barrel is pointing where you are looking.
|
||||||
|
box(v, i, -2, -1, -0.2, 0.9, 1.3, 1.4, 2)
|
||||||
|
box(v, i, 1.1, -1, -0.2, 0.9, 1.3, 1.4, 2)
|
||||||
|
box(v, i, -1.95, -0.2, 0.3, 0.5, 0.5, 0.5, 5)
|
||||||
|
box(v, i, 1.45, -0.2, 0.3, 0.5, 0.5, 0.5, 5)
|
||||||
|
box(v, i, -0.45, -1, 15.2, 0.9, 1.5, 1, 2)
|
||||||
|
box(v, i, -0.3, 0.1, 15.4, 0.6, 0.6, 0.6, 5)
|
||||||
|
bodyMesh = Voxel3D.newMesh(v, i)
|
||||||
|
return bodyMesh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the muzzle flash: a bright cross of boxes off the bore, drawn for two
|
||||||
|
-- frames after a shot and never lit by anything
|
||||||
|
local function buildFlash()
|
||||||
|
if flashMesh then return flashMesh end
|
||||||
|
local v, i = {}, {}
|
||||||
|
box(v, i, -1.6, -4.6, 17.4, 3.2, 3.2, 2.6, 8)
|
||||||
|
box(v, i, -3.4, -3.8, 17.6, 6.8, 1.6, 1.8, 9)
|
||||||
|
box(v, i, -0.9, -6.4, 17.6, 1.8, 6.4, 1.8, 9)
|
||||||
|
box(v, i, -1.1, -4.1, 19.6, 2.2, 2.2, 2.2, 9)
|
||||||
|
flashMesh = Voxel3D.newMesh(v, i)
|
||||||
|
return flashMesh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- state
|
||||||
|
|
||||||
|
local gun = {
|
||||||
|
ammo = HordeGun.MAG,
|
||||||
|
reloading = false,
|
||||||
|
reloadT = 0,
|
||||||
|
reloadStage = 0,
|
||||||
|
cooldown = 0,
|
||||||
|
ads = false,
|
||||||
|
adsBlend = 0,
|
||||||
|
kick = 0,
|
||||||
|
flash = 0,
|
||||||
|
frame = nil, -- the VR hand's model matrix for this frame
|
||||||
|
ray = nil, -- the VR aim ray in world space, if there is one
|
||||||
|
}
|
||||||
|
|
||||||
|
HordeGun.state = gun
|
||||||
|
|
||||||
|
function HordeGun.reset()
|
||||||
|
gun.ammo = HordeGun.MAG
|
||||||
|
gun.reloading, gun.reloadT, gun.reloadStage = false, 0, 0
|
||||||
|
gun.cooldown, gun.kick, gun.flash = 0, 0, 0
|
||||||
|
gun.ads, gun.adsBlend = false, 0
|
||||||
|
gun.frame, gun.ray = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- how far into the aim the sights are, 0..1 -- read by the HUD (the
|
||||||
|
-- crosshair goes away) and by the camera (the field of view narrows)
|
||||||
|
function HordeGun.adsBlend()
|
||||||
|
return gun.adsBlend
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.ammo()
|
||||||
|
return gun.ammo, HordeGun.MAG, gun.reloading
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.setAds(on)
|
||||||
|
gun.ads = on and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the shot
|
||||||
|
|
||||||
|
-- The eye and the direction it is looking, in world pixels. In VR this is
|
||||||
|
-- the gun's own barrel (set by the VR frame); on the flat screen it is
|
||||||
|
-- the camera, because the gun follows the camera exactly.
|
||||||
|
local function ray(G)
|
||||||
|
if gun.ray then return gun.ray end
|
||||||
|
local ow = G and G.overworld
|
||||||
|
if not (ow and ow.player and ow.map) then return nil end
|
||||||
|
local p = ow.player
|
||||||
|
local gh = 0
|
||||||
|
pcall(function()
|
||||||
|
gh = V.require("VoxelScene").groundAt(ow.map, p.cellX, p.cellY) or 0
|
||||||
|
end)
|
||||||
|
local cp = math.cos(FirstPerson.pitch)
|
||||||
|
return {
|
||||||
|
p.px + 8, gh + FirstPerson.EYE_HEIGHT, p.py + 8,
|
||||||
|
math.sin(FirstPerson.yaw) * cp,
|
||||||
|
-math.sin(FirstPerson.pitch),
|
||||||
|
math.cos(FirstPerson.yaw) * cp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How far the ray travels before terrain stops it. A wall in this world
|
||||||
|
-- is a cell whose ground stands taller than the ray does where it crosses
|
||||||
|
-- it, which is the same test for a fence you can shoot over, a building
|
||||||
|
-- you cannot, and a doorway you can shoot through.
|
||||||
|
local function occlusion(map, r)
|
||||||
|
local VoxelScene = V.require("VoxelScene")
|
||||||
|
local step = 3
|
||||||
|
local t = step
|
||||||
|
while t <= HordeGun.RANGE do
|
||||||
|
local x = r[1] + r[4] * t
|
||||||
|
local y = r[2] + r[5] * t
|
||||||
|
local z = r[3] + r[6] * t
|
||||||
|
local cx, cy = math.floor(x / 16), math.floor(z / 16)
|
||||||
|
if not map:inBounds(cx, cy) then return t end
|
||||||
|
local gh = 0
|
||||||
|
local ok, got = pcall(VoxelScene.groundAt, map, cx, cy)
|
||||||
|
if ok and got then gh = got end
|
||||||
|
if y < gh - 0.5 then return t end
|
||||||
|
if y < 0 then return t end
|
||||||
|
t = t + step
|
||||||
|
end
|
||||||
|
return HordeGun.RANGE
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The nearest mob the ray reaches, and whether it caught the head.
|
||||||
|
local function pick(G, r, maxT)
|
||||||
|
local Mobs = V.require("HordeMobs")
|
||||||
|
local VoxelScene = V.require("VoxelScene")
|
||||||
|
local ow = G and G.overworld
|
||||||
|
if not (ow and ow.map) then return nil end
|
||||||
|
local flat = r[4] * r[4] + r[6] * r[6]
|
||||||
|
if flat < 1e-6 then return nil end
|
||||||
|
local best, bestT, bestHead = nil, maxT, false
|
||||||
|
for _, e in ipairs(Mobs.list()) do
|
||||||
|
local npc = e.npc
|
||||||
|
if npc and not e.dead and e.mapId == ow.map.id then
|
||||||
|
local mx, mz = npc.px + 8, npc.py + 8
|
||||||
|
local t = ((mx - r[1]) * r[4] + (mz - r[3]) * r[6]) / flat
|
||||||
|
if t > 0 and t < bestT then
|
||||||
|
local hx = r[1] + r[4] * t - mx
|
||||||
|
local hz = r[3] + r[6] * t - mz
|
||||||
|
if hx * hx + hz * hz <= HordeGun.HIT_RADIUS * HordeGun.HIT_RADIUS then
|
||||||
|
local gh = 0
|
||||||
|
local ok, got = pcall(VoxelScene.groundAt, ow.map,
|
||||||
|
npc.cellX, npc.cellY)
|
||||||
|
if ok and got then gh = got end
|
||||||
|
local y = r[2] + r[5] * t
|
||||||
|
if y >= gh - 2 and y <= gh + 17 then
|
||||||
|
best, bestT, bestHead = e, t, y >= gh + 11
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best, bestHead
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pull the trigger. Every input device funnels here (see Horde.install,
|
||||||
|
-- FirstPerson's mouse and touch wraps, and VR.driveControls), so the
|
||||||
|
-- cooldown below is also what keeps two devices reporting the same press
|
||||||
|
-- from spending two rounds.
|
||||||
|
function HordeGun.fire()
|
||||||
|
if not Horde.playing() then return false end
|
||||||
|
if gun.cooldown > 0 or gun.reloading then return false end
|
||||||
|
if gun.ammo <= 0 then
|
||||||
|
gun.cooldown = 0.35
|
||||||
|
HordeSfx.play(HordeSfx.DRY)
|
||||||
|
HordeGun.reload()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local G = require("src.core.Game")
|
||||||
|
gun.ammo = gun.ammo - 1
|
||||||
|
gun.cooldown = HordeGun.FIRE_COOLDOWN
|
||||||
|
gun.kick = 1
|
||||||
|
gun.flash = 0.05
|
||||||
|
HordeSfx.shot()
|
||||||
|
|
||||||
|
local r = ray(G)
|
||||||
|
if r then
|
||||||
|
local ow = G.overworld
|
||||||
|
local maxT = ow and ow.map and occlusion(ow.map, r) or HordeGun.RANGE
|
||||||
|
local hit, head = pick(G, r, maxT)
|
||||||
|
if hit then
|
||||||
|
local Mobs = V.require("HordeMobs")
|
||||||
|
local result = Mobs.hit(hit, head and 2 or 1)
|
||||||
|
local s = Horde.session
|
||||||
|
if s then
|
||||||
|
s.hitMarker = 1
|
||||||
|
if result == "kill" and head then Horde.addScore(50) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if gun.ammo <= 0 then HordeGun.reload() end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.reload()
|
||||||
|
if gun.reloading or gun.ammo >= HordeGun.MAG then return false end
|
||||||
|
gun.reloading = true
|
||||||
|
gun.reloadT = 0
|
||||||
|
gun.reloadStage = 0
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the frame
|
||||||
|
|
||||||
|
function HordeGun.update(dt, live)
|
||||||
|
if not Horde.active then
|
||||||
|
FirstPerson.fovScale = 1 -- give the lens back on the way out
|
||||||
|
return
|
||||||
|
end
|
||||||
|
gun.cooldown = math.max(0, gun.cooldown - dt)
|
||||||
|
gun.kick = math.max(0, gun.kick - dt * 7)
|
||||||
|
gun.flash = math.max(0, gun.flash - dt)
|
||||||
|
|
||||||
|
local target = (gun.ads and live) and 1 or 0
|
||||||
|
local astep = dt / HordeGun.ADS_TIME
|
||||||
|
if gun.adsBlend < target then
|
||||||
|
gun.adsBlend = math.min(target, gun.adsBlend + astep)
|
||||||
|
else
|
||||||
|
gun.adsBlend = math.max(target, gun.adsBlend - astep)
|
||||||
|
end
|
||||||
|
-- the lens narrows with the sights. Half of what aiming does here is
|
||||||
|
-- the model coming to the centre of the screen; the other half is this
|
||||||
|
local e = gun.adsBlend * gun.adsBlend * (3 - 2 * gun.adsBlend)
|
||||||
|
FirstPerson.fovScale = 1 - (1 - HordeGun.ADS_FOV / FirstPerson.FOV) * e
|
||||||
|
|
||||||
|
if gun.reloading then
|
||||||
|
local was = gun.reloadT
|
||||||
|
gun.reloadT = gun.reloadT + dt
|
||||||
|
-- three clicks on their own clock: the magazine out, the fresh one
|
||||||
|
-- in, the slide home. Staged by time rather than animated frames so
|
||||||
|
-- the sound and the dip below stay in step at any frame rate.
|
||||||
|
local marks = { { 0.10, HordeSfx.MAG_OUT }, { 0.62, HordeSfx.MAG_IN },
|
||||||
|
{ 1.15, HordeSfx.RACK } }
|
||||||
|
for _, m in ipairs(marks) do
|
||||||
|
if was < m[1] and gun.reloadT >= m[1] then HordeSfx.play(m[2]) end
|
||||||
|
end
|
||||||
|
if gun.reloadT >= HordeGun.RELOAD_TIME then
|
||||||
|
gun.reloading = false
|
||||||
|
gun.reloadT = 0
|
||||||
|
gun.ammo = HordeGun.MAG
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- VR placement
|
||||||
|
--
|
||||||
|
-- Called from the VR frame with the same mapping the eyes got. `pose` is
|
||||||
|
-- the tracked right hand -- the runtime's aim pose where it has one.
|
||||||
|
|
||||||
|
function HordeGun.place(pose, pivot, anchor, scale, yaw)
|
||||||
|
if not (Horde.active and pose) then
|
||||||
|
HordeGun.clear()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||||
|
m = Mat4.mul(m, Mat4.translate(HordeGun.HAND_OFFSET[1],
|
||||||
|
HordeGun.HAND_OFFSET[2],
|
||||||
|
HordeGun.HAND_OFFSET[3]))
|
||||||
|
m = Mat4.mul(m, Mat4.rotateY(HordeGun.HAND_YAW))
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX(HordeGun.HAND_PITCH))
|
||||||
|
-- the recoil, up and back along the gun's own axes
|
||||||
|
local k = gun.kick
|
||||||
|
if k > 0 then
|
||||||
|
m = Mat4.mul(m, Mat4.translate(0, 0, -0.05 * k))
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX(-0.30 * k))
|
||||||
|
end
|
||||||
|
gun.frame = m
|
||||||
|
|
||||||
|
-- the barrel, in world pixels: the shot goes where the gun points, so
|
||||||
|
-- lining the sights up with an eye is what aims it
|
||||||
|
local o = { m[4], m[8], m[12] }
|
||||||
|
local dx, dy, dz = m[3], m[7], m[11] -- the model's +Z column
|
||||||
|
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
if len > 1e-6 then
|
||||||
|
gun.ray = { o[1], o[2], o[3], dx / len, dy / len, dz / len }
|
||||||
|
else
|
||||||
|
gun.ray = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.clear()
|
||||||
|
gun.frame, gun.ray = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- drawing
|
||||||
|
--
|
||||||
|
-- Runs inside VoxelScene's drawScene, once per eye in VR and once per
|
||||||
|
-- frame flat, after the world -- so the gun composites with real depth
|
||||||
|
-- and leaning it into a wall occludes honestly.
|
||||||
|
|
||||||
|
-- Should the gun be drawn at all this frame? Keyed on the first-person
|
||||||
|
-- rig's own IDENTITY rather than on the rung's number, because a staged
|
||||||
|
-- VR battle places a camera through the same seam and the gun has no
|
||||||
|
-- business in it.
|
||||||
|
function HordeGun.visible()
|
||||||
|
if not Horde.active then return false end
|
||||||
|
if gun.frame then return true end
|
||||||
|
return FirstPerson.cardBlend() > 0.35
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The flat screen's view model matrix: carried by the camera, offset to
|
||||||
|
-- the hip or the sight line, with the recoil on top.
|
||||||
|
local function flatModel()
|
||||||
|
local cam = Voxel3D.camera
|
||||||
|
local eye = cam and cam.eye
|
||||||
|
if not eye then return nil end
|
||||||
|
local a = gun.adsBlend
|
||||||
|
a = a * a * (3 - 2 * a)
|
||||||
|
local hip, ads = HordeGun.HIP, HordeGun.ADS
|
||||||
|
local ox = hip[1] + (ads[1] - hip[1]) * a
|
||||||
|
local oy = hip[2] + (ads[2] - hip[2]) * a
|
||||||
|
local oz = hip[3] + (ads[3] - hip[3]) * a
|
||||||
|
-- the reload dip: the gun swings down and out of the shot while the
|
||||||
|
-- hands are busy, easing back as the slide comes home
|
||||||
|
if gun.reloading then
|
||||||
|
local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME)
|
||||||
|
local dip = math.sin(math.min(1, t * 1.15) * math.pi)
|
||||||
|
oy = oy - 0.09 * dip
|
||||||
|
ox = ox - 0.03 * dip
|
||||||
|
end
|
||||||
|
local k = gun.kick
|
||||||
|
oz = oz - 0.045 * k
|
||||||
|
|
||||||
|
local m = Mat4.translate(eye[1], eye[2], eye[3])
|
||||||
|
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.yaw))
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX(FirstPerson.pitch - 0.34 * k))
|
||||||
|
m = Mat4.mul(m, Mat4.scale(VRRig.FP_SCALE, VRRig.FP_SCALE, VRRig.FP_SCALE))
|
||||||
|
m = Mat4.mul(m, Mat4.translate(ox, oy, oz))
|
||||||
|
if gun.reloading then
|
||||||
|
local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME)
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX(-0.55 * math.sin(math.min(1, t * 1.15)
|
||||||
|
* math.pi)))
|
||||||
|
end
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.draw()
|
||||||
|
if not HordeGun.visible() then return end
|
||||||
|
local model = gun.frame or flatModel()
|
||||||
|
if not model then return end
|
||||||
|
local body, pal = buildBody(), palette()
|
||||||
|
if not (body and pal) then return end
|
||||||
|
Voxel3D.draw(body, pal, model)
|
||||||
|
if gun.flash > 0 then
|
||||||
|
local flash = buildFlash()
|
||||||
|
if flash then Voxel3D.draw(flash, pal, model) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeGun.invalidate()
|
||||||
|
paletteTex, bodyMesh, flashMesh = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return HordeGun
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
-- HORDE MODE: the readout.
|
||||||
|
--
|
||||||
|
-- Health, ammunition, score, wave, the crosshair, the hit marker, the red
|
||||||
|
-- that closes in when something reaches you, and the banners -- "A
|
||||||
|
-- DARKNESS APPROACHES", then "WAVE 1" and every wave after it.
|
||||||
|
--
|
||||||
|
-- IT IS DRAWN TWICE, INTO TWO DIFFERENT PLACES, and that is not
|
||||||
|
-- duplication for its own sake. The flat screen's HUD goes into the SCENE
|
||||||
|
-- canvas through Voxel3D.beginOverlay -- the same seam the overworld's FX
|
||||||
|
-- bubbles use -- because that canvas is what the window composites. A
|
||||||
|
-- headset never sees that canvas: with VR live the window's world pass
|
||||||
|
-- short-circuits to the mirror, and the eyes are rendered on their own in
|
||||||
|
-- lib/VR. So the eye canvases get their own pass, at the same instant the
|
||||||
|
-- VR frame paints its fade over them, in the same 2D idiom.
|
||||||
|
--
|
||||||
|
-- Both call the same draw with a different scale and a different safe
|
||||||
|
-- area: a headset wants everything well inside the lens rather than
|
||||||
|
-- pinned to the corners, because the corners of a VR frame are off the
|
||||||
|
-- edge of the visible world.
|
||||||
|
--
|
||||||
|
-- EVERY WORD IS ON A WHITE PLATE, and that is not a style choice -- it is
|
||||||
|
-- what the font is. The Game Boy font sheets are BLACK glyphs on
|
||||||
|
-- transparent, so setColor cannot make a letter pale: multiplying black
|
||||||
|
-- by white is still black. That is why every box in the game is drawn
|
||||||
|
-- white first and its text black on top (Font.drawBox, then
|
||||||
|
-- setColor(0,0,0)), and it is why a first cut of this HUD -- pale text,
|
||||||
|
-- straight onto the night -- composited as black letters on a black
|
||||||
|
-- street and could not be read at all. Plates also happen to be the right
|
||||||
|
-- answer aesthetically: the game already talks to the player in white
|
||||||
|
-- boxes, and a horde mode that shouts in the same voice belongs to it.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
|
||||||
|
local HordeHud = {}
|
||||||
|
|
||||||
|
local Font = nil
|
||||||
|
local function font()
|
||||||
|
if Font then return Font end
|
||||||
|
local ok, F = pcall(require, "src.render.Font")
|
||||||
|
if ok then Font = F end
|
||||||
|
return Font
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the pulse under the low-health plate and the banner's own breathing
|
||||||
|
local blink = 0
|
||||||
|
|
||||||
|
function HordeHud.update(dt)
|
||||||
|
blink = (blink + (dt or 0)) % 1.0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- named for the suite: the banner's line breaking, which is the part with
|
||||||
|
-- an answer worth pinning
|
||||||
|
HordeHud._layout = nil -- assigned below, once `layout` exists
|
||||||
|
|
||||||
|
-- ------- pieces
|
||||||
|
--
|
||||||
|
-- Every helper takes a scale `s` and draws in GB pixels multiplied by it,
|
||||||
|
-- so one layout serves a 4x window and a headset's eye buffer alike.
|
||||||
|
|
||||||
|
local PAD = 3 -- plate padding, in GB pixels
|
||||||
|
|
||||||
|
-- A white plate with a dark edge: the surface a black glyph can be read
|
||||||
|
-- on. Returns the interior origin, so a caller lays text out from there.
|
||||||
|
local function plate(x, y, w, h, s, alpha)
|
||||||
|
love.graphics.setColor(0.06, 0.05, 0.09, (alpha or 1) * 0.92)
|
||||||
|
love.graphics.rectangle("fill", x - s, y - s, w + 2 * s, h + 2 * s)
|
||||||
|
love.graphics.setColor(0.93, 0.94, 0.90, alpha or 1)
|
||||||
|
love.graphics.rectangle("fill", x, y, w, h)
|
||||||
|
return x + PAD * s, y + PAD * s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textWidth(str, s)
|
||||||
|
local F = font()
|
||||||
|
if not F then return 0 end
|
||||||
|
return F.width(str) * s
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Black glyphs at `s` times their size. Black because that is the only
|
||||||
|
-- colour the font has (see the header).
|
||||||
|
local function text(str, x, y, s)
|
||||||
|
local F = font()
|
||||||
|
if not F then return 0 end
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(math.floor(x), math.floor(y))
|
||||||
|
love.graphics.scale(s, s)
|
||||||
|
F.draw(str, 0, 0)
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One line of text on its own plate, anchored left or right.
|
||||||
|
local function label(str, x, y, s, align)
|
||||||
|
local tw = textWidth(str, s)
|
||||||
|
local pw, ph = tw + PAD * 2 * s, 8 * s + PAD * 2 * s
|
||||||
|
local px = (align == "right") and (x - pw) or x
|
||||||
|
local ix, iy = plate(px, y, pw, ph, s)
|
||||||
|
text(str, ix, iy, s)
|
||||||
|
return pw, ph
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The health bar: a plate with a red bar inside it, so the red reads
|
||||||
|
-- against white rather than against a night street.
|
||||||
|
local function healthBar(x, y, w, h, s, fill, flash)
|
||||||
|
local ix, iy = plate(x, y, w, h, s)
|
||||||
|
local iw, ih = w - PAD * 2 * s, h - PAD * 2 * s
|
||||||
|
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||||
|
love.graphics.rectangle("fill", ix, iy, iw, ih)
|
||||||
|
local r, g, b = 0.78, 0.12, 0.16
|
||||||
|
if flash then r, g, b = 1, 0.45, 0.35 end
|
||||||
|
love.graphics.setColor(r, g, b, 1)
|
||||||
|
love.graphics.rectangle("fill", ix, iy, math.max(0, iw * fill), ih)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The crosshair: four ticks around a gap that opens as the gun kicks, and
|
||||||
|
-- gone entirely down the sights, where the iron sights ARE the crosshair.
|
||||||
|
-- Drawn as a dark pair under a light pair so it survives both a white
|
||||||
|
-- wall and a black doorway.
|
||||||
|
local function crosshair(cx, cy, s, spread, alpha)
|
||||||
|
local gap = (3 + spread * 4) * s
|
||||||
|
local len = 4 * s
|
||||||
|
local t = math.max(1, s)
|
||||||
|
local function ticks(o, thick, r, g, b, a)
|
||||||
|
love.graphics.setColor(r, g, b, a)
|
||||||
|
love.graphics.rectangle("fill", cx - gap - len - o, cy - thick / 2 - o,
|
||||||
|
len + 2 * o, thick + 2 * o)
|
||||||
|
love.graphics.rectangle("fill", cx + gap - o, cy - thick / 2 - o,
|
||||||
|
len + 2 * o, thick + 2 * o)
|
||||||
|
love.graphics.rectangle("fill", cx - thick / 2 - o, cy - gap - len - o,
|
||||||
|
thick + 2 * o, len + 2 * o)
|
||||||
|
love.graphics.rectangle("fill", cx - thick / 2 - o, cy + gap - o,
|
||||||
|
thick + 2 * o, len + 2 * o)
|
||||||
|
end
|
||||||
|
ticks(math.max(1, s * 0.5), t, 0, 0, 0, alpha * 0.85)
|
||||||
|
ticks(0, t, 0.98, 0.98, 1, alpha)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hitMarker(cx, cy, s, amount)
|
||||||
|
if amount <= 0 then return end
|
||||||
|
love.graphics.setColor(1, 0.30, 0.26, amount)
|
||||||
|
local o = 5 * s
|
||||||
|
local len = 5 * s
|
||||||
|
local t = math.max(1, s)
|
||||||
|
for _, d in ipairs({ { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }) do
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(cx + d[1] * o, cy + d[2] * o)
|
||||||
|
love.graphics.rotate(math.pi / 4 * (d[1] * d[2] > 0 and 1 or -1))
|
||||||
|
love.graphics.rectangle("fill", -t / 2, -len / 2, t, len)
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How wide a run of glyphs comes out at `bs` pixels per font pixel, with
|
||||||
|
-- `track` of air after each one.
|
||||||
|
local function runWidth(F, codes, bs, track)
|
||||||
|
local total = 0
|
||||||
|
for _, code in ipairs(codes) do
|
||||||
|
total = total + F.advanceOf(code) * bs + track
|
||||||
|
end
|
||||||
|
return total - track
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The banner's lines, and the size to draw them at.
|
||||||
|
--
|
||||||
|
-- THE SCALE IS NEGOTIATED, not assumed. The caller's scale comes from the
|
||||||
|
-- window's own zoom, so a player zoomed well in gets a large `s` -- and
|
||||||
|
-- "A DARKNESS APPROACHES" at twice a large scale is wider than the
|
||||||
|
-- screen, which is how the words ran off both edges. So: shrink until the
|
||||||
|
-- longest single WORD fits, then wrap the words into as many lines as
|
||||||
|
-- that leaves. Wrapping first and shrinking only when a word alone cannot
|
||||||
|
-- fit keeps the announcement as big as the frame can carry it.
|
||||||
|
local function layout(F, str, scale, maxW)
|
||||||
|
local words = {}
|
||||||
|
for word in tostring(str):gmatch("%S+") do words[#words + 1] = word end
|
||||||
|
if #words == 0 then return nil end
|
||||||
|
|
||||||
|
local bs = math.max(1, scale * 2)
|
||||||
|
local function track(size) return math.max(1, math.floor(size / 2)) end
|
||||||
|
while bs > 1 do
|
||||||
|
local widest = 0
|
||||||
|
for _, word in ipairs(words) do
|
||||||
|
local ww = runWidth(F, F.encode(word), bs, track(bs))
|
||||||
|
if ww > widest then widest = ww end
|
||||||
|
end
|
||||||
|
if widest <= maxW then break end
|
||||||
|
bs = bs - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local tr = track(bs)
|
||||||
|
local spaceW = runWidth(F, F.encode(" "), bs, tr) + tr
|
||||||
|
local lines, line, lineW = {}, nil, 0
|
||||||
|
for _, word in ipairs(words) do
|
||||||
|
local ww = runWidth(F, F.encode(word), bs, tr)
|
||||||
|
if not line then
|
||||||
|
line, lineW = word, ww
|
||||||
|
elseif lineW + spaceW + ww <= maxW then
|
||||||
|
line, lineW = line .. " " .. word, lineW + spaceW + ww
|
||||||
|
else
|
||||||
|
lines[#lines + 1] = { text = line, width = lineW }
|
||||||
|
line, lineW = word, ww
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lines[#lines + 1] = { text = line, width = lineW }
|
||||||
|
return lines, bs, tr
|
||||||
|
end
|
||||||
|
|
||||||
|
HordeHud._layout = layout
|
||||||
|
|
||||||
|
-- The banner: a plate across the middle of the frame with the words on
|
||||||
|
-- it, as big as the frame can carry. It fades in and out rather than
|
||||||
|
-- cutting -- an announcement, not a notification -- and the plate fades
|
||||||
|
-- with it.
|
||||||
|
local function banner(w, h, scale)
|
||||||
|
local sess = Horde.session
|
||||||
|
if not (sess and sess.bannerText) then return end
|
||||||
|
local t, hold = sess.bannerT, sess.bannerHold
|
||||||
|
local alpha
|
||||||
|
if t < 0.4 then alpha = t / 0.4
|
||||||
|
elseif t < hold then alpha = 1
|
||||||
|
else alpha = math.max(0, 1 - (t - hold) / 1.1) end
|
||||||
|
if alpha <= 0 then return end
|
||||||
|
|
||||||
|
local F = font()
|
||||||
|
if not F then return end
|
||||||
|
local margin = 6 * scale
|
||||||
|
local lines, bs, tr = layout(F, sess.bannerText, scale, w - margin * 2)
|
||||||
|
if not lines then return end
|
||||||
|
|
||||||
|
local lineH = 8 * bs
|
||||||
|
local gap = math.max(1, math.floor(bs * 0.4))
|
||||||
|
local pad = PAD * 2 * scale
|
||||||
|
local ph = #lines * lineH + (#lines - 1) * gap + pad * 2
|
||||||
|
local y = math.floor(h * 0.30 - ph / 2)
|
||||||
|
-- the plate runs the full width: a band across the world, which reads
|
||||||
|
-- as the game interrupting itself rather than as a label on it
|
||||||
|
plate(0, y, w, ph, scale, alpha)
|
||||||
|
local iy = y + pad
|
||||||
|
|
||||||
|
love.graphics.setColor(0, 0, 0, alpha)
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
local pen = math.floor((w - line.width) / 2)
|
||||||
|
local ly = iy + (i - 1) * (lineH + gap)
|
||||||
|
for _, code in ipairs(F.encode(line.text)) do
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(pen, ly)
|
||||||
|
love.graphics.scale(bs, bs)
|
||||||
|
F.drawCode(code, 0, 0)
|
||||||
|
love.graphics.pop()
|
||||||
|
pen = pen + F.advanceOf(code) * bs + tr
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the whole thing
|
||||||
|
--
|
||||||
|
-- `inset` is how far off the edges the corners sit, which is the one real
|
||||||
|
-- difference between a window and a headset.
|
||||||
|
|
||||||
|
local function draw(w, h, s, inset)
|
||||||
|
local sess = Horde.session
|
||||||
|
if not sess then return end
|
||||||
|
local Gun = V.require("HordeGun")
|
||||||
|
local ammo, mag, reloading = Gun.ammo()
|
||||||
|
local ads = Gun.adsBlend()
|
||||||
|
|
||||||
|
love.graphics.push("all")
|
||||||
|
love.graphics.setBlendMode("alpha")
|
||||||
|
|
||||||
|
-- The red. A VIGNETTE rather than a wash over everything: a full-screen
|
||||||
|
-- fill strong enough to register at a glance also hides the thing that
|
||||||
|
-- just hit you, which in a mode about being surrounded is the one thing
|
||||||
|
-- it must not do. Bands closing in from the edges instead, so the
|
||||||
|
-- middle of the frame stays readable and the alarm arrives in the
|
||||||
|
-- corner of the eye.
|
||||||
|
local hurt = sess.damageFlash
|
||||||
|
local low = 1 - math.min(1, sess.hp / (sess.maxHp * 0.35))
|
||||||
|
local wash = math.max(hurt * 0.9, low * 0.6
|
||||||
|
* (0.7 + 0.3 * math.sin(blink * math.pi * 2)))
|
||||||
|
if wash > 0 then
|
||||||
|
local band = math.min(w, h) * 0.38
|
||||||
|
local steps = 8
|
||||||
|
for i = 1, steps do
|
||||||
|
local t = i / steps
|
||||||
|
local d = band * t
|
||||||
|
love.graphics.setColor(0.60, 0.02, 0.06, wash * 0.13)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, w, d)
|
||||||
|
love.graphics.rectangle("fill", 0, h - d, w, d)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, d, h)
|
||||||
|
love.graphics.rectangle("fill", w - d, 0, d, h)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- health, top left
|
||||||
|
local barW, barH = 60 * s, 8 * s + PAD * 2 * s
|
||||||
|
healthBar(inset, inset, barW, barH, s, sess.hp / sess.maxHp, hurt > 0.3)
|
||||||
|
label(("%d"):format(math.ceil(sess.hp)), inset, inset + barH + 3 * s, s)
|
||||||
|
|
||||||
|
-- score and wave, top right
|
||||||
|
label(("SCORE %d"):format(math.floor(sess.score)), w - inset, inset, s,
|
||||||
|
"right")
|
||||||
|
label(("WAVE %d"):format(math.max(1, sess.wave)),
|
||||||
|
w - inset, inset + (8 * s + PAD * 2 * s) + 3 * s, s, "right")
|
||||||
|
|
||||||
|
-- ammunition, bottom right: the rounds as pips over the count, which
|
||||||
|
-- reads at a glance in a firefight where a number does not
|
||||||
|
local ammoStr = reloading and "RELOADING" or ("%d / %d"):format(ammo, mag)
|
||||||
|
local _, ah = label(ammoStr, w - inset, h - inset - (8 * s + PAD * 2 * s), s,
|
||||||
|
"right")
|
||||||
|
local pipW, pipH, pipGap = 3 * s, 7 * s, 2 * s
|
||||||
|
local pipsW = mag * (pipW + pipGap) - pipGap
|
||||||
|
local px = w - inset - pipsW
|
||||||
|
local py = h - inset - ah - pipH - 5 * s
|
||||||
|
love.graphics.setColor(0.06, 0.05, 0.09, 0.85)
|
||||||
|
love.graphics.rectangle("fill", px - 2 * s, py - 2 * s,
|
||||||
|
pipsW + 4 * s, pipH + 4 * s)
|
||||||
|
for i = 1, mag do
|
||||||
|
if i <= ammo and not reloading then
|
||||||
|
love.graphics.setColor(0.98, 0.86, 0.36, 1)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0.32, 0.30, 0.36, 1)
|
||||||
|
end
|
||||||
|
love.graphics.rectangle("fill", px + (i - 1) * (pipW + pipGap), py,
|
||||||
|
pipW, pipH)
|
||||||
|
end
|
||||||
|
if reloading then
|
||||||
|
local t = math.min(1, Gun.state.reloadT / Gun.RELOAD_TIME)
|
||||||
|
love.graphics.setColor(0.55, 0.78, 0.98, 1)
|
||||||
|
love.graphics.rectangle("fill", px, py + pipH + 1 * s, pipsW * t, 2 * s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the sight picture
|
||||||
|
local cx, cy = w / 2, h / 2
|
||||||
|
if ads < 0.6 then
|
||||||
|
crosshair(cx, cy, s, Gun.state.kick, (1 - ads / 0.6) * 0.9)
|
||||||
|
end
|
||||||
|
hitMarker(cx, cy, s, sess.hitMarker)
|
||||||
|
|
||||||
|
banner(w, h, s)
|
||||||
|
|
||||||
|
love.graphics.pop()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the two callers
|
||||||
|
|
||||||
|
-- The flat window. Called from the voxel pipeline's overlay block, into
|
||||||
|
-- the scene canvas -- which is at the window's PIXEL size and may be
|
||||||
|
-- supersampled on top of that, so the caller's scale carries both.
|
||||||
|
--
|
||||||
|
-- The caller's scale is CAPPED against the canvas rather than taken as
|
||||||
|
-- given, because that scale is the world's zoom: zoom in far enough and
|
||||||
|
-- the health bar was a metre wide and half of it off the top of the
|
||||||
|
-- screen. A readout is not part of the world and should not zoom with
|
||||||
|
-- it -- so it sizes off the frame it is drawn in, which keeps its
|
||||||
|
-- apparent size the same at every zoom and grows it honestly on a bigger
|
||||||
|
-- display (and with supersampling, which is in both numbers).
|
||||||
|
function HordeHud.drawFlat(w, h, scale)
|
||||||
|
if not Horde.active then return end
|
||||||
|
local cap = math.max(1, math.floor(h / 260))
|
||||||
|
local s = math.max(1, math.min(math.floor((scale or 1) + 0.5), cap))
|
||||||
|
draw(w, h, s, 8 * s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- and the headset's, which is not a screen overlay at all
|
||||||
|
--
|
||||||
|
-- A VR eye gets NO 2D overlay. An earlier cut drew this same HUD into
|
||||||
|
-- both eye canvases and it came out torn down the middle: the eye frusta
|
||||||
|
-- are ASYMMETRIC, so the same canvas pixel is a different ANGLE in each
|
||||||
|
-- eye, and the two images never fuse. Nor is there a crosshair to draw --
|
||||||
|
-- the gun is a real object with real sights and the shot goes down its
|
||||||
|
-- barrel, so a dot painted at the centre of the frame would be pointing
|
||||||
|
-- at something else entirely.
|
||||||
|
--
|
||||||
|
-- What the headset gets instead is this: the readout as a TEXTURE, which
|
||||||
|
-- lib/VR puts on the POKEDEX in the player's left hand -- already
|
||||||
|
-- tracked, already lit, and already the surface this mod shows
|
||||||
|
-- information on. Geometry in the world, so both eyes see it from their
|
||||||
|
-- own position and the stereo is correct by construction. (It rode the
|
||||||
|
-- gun for one revision and that was worse: a screen on the slide sits
|
||||||
|
-- exactly where the iron sights have to be looked through.)
|
||||||
|
--
|
||||||
|
-- Sized to the device's own screen, which is the GB frame's 10:9.
|
||||||
|
|
||||||
|
local panelCanvas = nil
|
||||||
|
local PANEL_W, PANEL_H = 160, 144
|
||||||
|
|
||||||
|
function HordeHud.panelTexture()
|
||||||
|
if not Horde.active then return nil end
|
||||||
|
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||||
|
local sess = Horde.session
|
||||||
|
if not sess then return nil end
|
||||||
|
local F = font()
|
||||||
|
if not F then return nil end
|
||||||
|
|
||||||
|
if not panelCanvas then
|
||||||
|
local ok, c = pcall(love.graphics.newCanvas, PANEL_W, PANEL_H)
|
||||||
|
if not ok then return nil end
|
||||||
|
panelCanvas = c
|
||||||
|
pcall(panelCanvas.setFilter, panelCanvas, "nearest", "nearest")
|
||||||
|
end
|
||||||
|
|
||||||
|
local Gun = V.require("HordeGun")
|
||||||
|
local ammo, mag, reloading = Gun.ammo()
|
||||||
|
|
||||||
|
local ok = pcall(function()
|
||||||
|
love.graphics.push("all")
|
||||||
|
love.graphics.setCanvas(panelCanvas)
|
||||||
|
love.graphics.setBlendMode("alpha")
|
||||||
|
love.graphics.clear(0.93, 0.94, 0.90, 1)
|
||||||
|
|
||||||
|
-- the health bar, framed, across the top
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.rectangle("fill", 8, 8, PANEL_W - 16, 20)
|
||||||
|
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||||
|
love.graphics.rectangle("fill", 11, 11, PANEL_W - 22, 14)
|
||||||
|
love.graphics.setColor(0.78, 0.12, 0.16, 1)
|
||||||
|
love.graphics.rectangle("fill", 11, 11,
|
||||||
|
(PANEL_W - 22) * math.max(0, sess.hp / sess.maxHp),
|
||||||
|
14)
|
||||||
|
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
F.draw(("HP %d"):format(math.ceil(sess.hp)), 8, 34)
|
||||||
|
F.draw(reloading and "RELOADING" or ("AMMO %d/%d"):format(ammo, mag),
|
||||||
|
8, 50)
|
||||||
|
|
||||||
|
-- the round pips, so ammunition reads without counting digits
|
||||||
|
local pipW, gap = 9, 5
|
||||||
|
for i = 1, mag do
|
||||||
|
if i <= ammo and not reloading then
|
||||||
|
love.graphics.setColor(0.85, 0.65, 0.10, 1)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0.72, 0.73, 0.70, 1)
|
||||||
|
end
|
||||||
|
love.graphics.rectangle("fill", 8 + (i - 1) * (pipW + gap), 66, pipW, 12)
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
F.draw(("WAVE %d"):format(math.max(1, sess.wave)), 8, 86)
|
||||||
|
F.draw(("%d"):format(math.floor(sess.score)), 8, 102)
|
||||||
|
|
||||||
|
-- and the banner, wrapped to the panel rather than to the frame.
|
||||||
|
-- BLACK on the panel's own white, like everything else here: the font
|
||||||
|
-- sheets are black glyphs on transparent, so a pale letter is not a
|
||||||
|
-- thing that can be drawn (see the header).
|
||||||
|
if sess.bannerText then
|
||||||
|
local lines, bs, tr = layout(F, sess.bannerText, 1, PANEL_W - 8)
|
||||||
|
if lines then
|
||||||
|
local top = PANEL_H - 8 * bs * #lines - 5
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, top - 2, PANEL_W, 1)
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
local pen = math.floor((PANEL_W - line.width) / 2)
|
||||||
|
local ly = PANEL_H - 8 * bs * (#lines - i + 1) - 3
|
||||||
|
for _, code in ipairs(F.encode(line.text)) do
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(pen, ly)
|
||||||
|
love.graphics.scale(bs, bs)
|
||||||
|
F.drawCode(code, 0, 0)
|
||||||
|
love.graphics.pop()
|
||||||
|
pen = pen + F.advanceOf(code) * bs + tr
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.setCanvas()
|
||||||
|
love.graphics.pop()
|
||||||
|
end)
|
||||||
|
pcall(love.graphics.setCanvas)
|
||||||
|
if not ok then return nil end
|
||||||
|
return panelCanvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- window resize / hot reload
|
||||||
|
function HordeHud.invalidate()
|
||||||
|
if panelCanvas and panelCanvas.release then
|
||||||
|
pcall(panelCanvas.release, panelCanvas)
|
||||||
|
end
|
||||||
|
panelCanvas = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return HordeHud
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
-- HORDE MODE: the crowd.
|
||||||
|
--
|
||||||
|
-- Waves of people who want to touch you, walking the same grid the game
|
||||||
|
-- walks, wearing the overworld's own character sheets. Every mob IS a
|
||||||
|
-- real engine NPC (OverworldState:addRuntimeObject), which is what buys
|
||||||
|
-- the whole feature for nothing: the engine interpolates their steps,
|
||||||
|
-- the collision system lets them jostle, the voxel pass billboards them
|
||||||
|
-- with the right frame for the angle you see them from, and the flat 2D
|
||||||
|
-- path draws them too. Nothing here draws a character.
|
||||||
|
--
|
||||||
|
-- THEY ARE DRIVEN, NOT SCRIPTED. OverworldState:scriptMove would be the
|
||||||
|
-- obvious way to walk one, and it is a trap: a queued script move sets
|
||||||
|
-- `scripted` on the state, which blocks the PLAYER's input for as long as
|
||||||
|
-- it runs. So mobs are spawned with movement = "STAY" (which leaves
|
||||||
|
-- NPC:update's wander branch inert) and this file writes facing / target /
|
||||||
|
-- moving / progress directly, once per step. NPC:update then does the
|
||||||
|
-- pixel interpolation and the cell commit exactly as it does for a
|
||||||
|
-- wandering shopkeeper.
|
||||||
|
--
|
||||||
|
-- PATHING IS A FLOW FIELD, not A* per mob. One breadth-first sweep out
|
||||||
|
-- from the player's cell, over the map's walkable cells, gives EVERY mob
|
||||||
|
-- its next step at once -- and gives it correctly through doorways and
|
||||||
|
-- around buildings, which is what "gang up on the player" actually
|
||||||
|
-- requires. Rebuilt a few times a second rather than per frame; between
|
||||||
|
-- rebuilds a mob just walks downhill on the numbers. It also answers two
|
||||||
|
-- other questions for free: how far a cell is from the player (so a spawn
|
||||||
|
-- point can be picked at a fair distance and be guaranteed REACHABLE),
|
||||||
|
-- and whether a mob is adjacent enough to swing.
|
||||||
|
--
|
||||||
|
-- The sweep ignores entity occupancy on purpose. Mobs are solid to each
|
||||||
|
-- other, so a pack funnelling down a corridor will jam -- and the fix for
|
||||||
|
-- that is not a cleverer path, it is that a mob whose downhill step is
|
||||||
|
-- occupied tries its second choice and otherwise waits. That is what
|
||||||
|
-- makes them pool around the player instead of forming a queue.
|
||||||
|
--
|
||||||
|
-- FOLLOWING THROUGH DOORS. A warp tears down every NPC on the old map, so
|
||||||
|
-- the roster cannot survive one. What survives is the COUNT: the number
|
||||||
|
-- still alive when the player ran, re-spawned on the far side over the
|
||||||
|
-- next few seconds, from the cells nearest the door they came in by. From
|
||||||
|
-- the player's chair that is the horde coming through the door after them.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
local HordeSfx = V.require("HordeSfx")
|
||||||
|
|
||||||
|
local HordeMobs = {}
|
||||||
|
|
||||||
|
-- ------- tuning
|
||||||
|
|
||||||
|
-- Wave n throws this many at you, and no more than CAP stand at once.
|
||||||
|
local function waveSize(n) return 4 + 3 * n end
|
||||||
|
local CAP = 14
|
||||||
|
local SPAWN_INTERVAL = 0.75 -- seconds between arrivals inside a wave
|
||||||
|
local WAVE_GAP = 4.0 -- the breather, and the banner's window
|
||||||
|
local FOLLOW_INTERVAL = 0.55 -- how fast they pour through a door
|
||||||
|
|
||||||
|
-- Frames per cell. The engine's own walk is 16; the horde is quicker than
|
||||||
|
-- a shopkeeper and gets quicker as the waves stack, floored so it never
|
||||||
|
-- outruns the player's own free walk.
|
||||||
|
local function stepFrames(n)
|
||||||
|
return math.max(9, 15 - math.floor(n / 2))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mobHp(n) return math.min(4, 1 + math.floor(n / 3)) end
|
||||||
|
local function killScore(n) return 100 + 25 * (n - 1) end
|
||||||
|
local function waveBonus(n) return 250 * n end
|
||||||
|
|
||||||
|
-- How close a mob comes before it stops walking and starts swinging, in
|
||||||
|
-- CELLS, and how close it has to be to land the hit, in world pixels.
|
||||||
|
--
|
||||||
|
-- The standoff is the difference between a horde and a wall. Nothing
|
||||||
|
-- stops a mob taking the cell next to the player -- and when it does, a
|
||||||
|
-- sixteen-pixel figure a cell away fills a sixty-five-degree lens edge to
|
||||||
|
-- edge, so being surrounded looks like a texture rather than like people.
|
||||||
|
-- Two cells back they read as figures closing in, the ring holds a dozen
|
||||||
|
-- of them, and the player can still see what they are shooting at.
|
||||||
|
local STANDOFF = 2
|
||||||
|
local REACH = 40
|
||||||
|
|
||||||
|
-- The cast. Overworld sprite sheets that read as a threat coming out of
|
||||||
|
-- the dark; anything missing from the loaded game is dropped at spawn.
|
||||||
|
local CAST = {
|
||||||
|
"SPRITE_ROCKET", "SPRITE_CHANNELER", "SPRITE_SCIENTIST", "SPRITE_BIKER",
|
||||||
|
"SPRITE_GUARD", "SPRITE_SUPER_NERD", "SPRITE_HIKER", "SPRITE_SWIMMER",
|
||||||
|
"SPRITE_GYM_GUIDE", "SPRITE_BLACK_HAIR_BOY_1", "SPRITE_GIRL",
|
||||||
|
"SPRITE_MIDDLE_AGED_MAN", "SPRITE_FISHER", "SPRITE_GAMBLER",
|
||||||
|
}
|
||||||
|
|
||||||
|
local OWNER = "DRAMATIC_SHAPE"
|
||||||
|
|
||||||
|
-- ------- the flow field
|
||||||
|
--
|
||||||
|
-- dist[cy * w + cx] = steps from the player, over walkable cells only.
|
||||||
|
-- Nil where the sweep never reached, which is the same answer as "no way
|
||||||
|
-- there from here" -- an island across water, a room behind a locked door.
|
||||||
|
|
||||||
|
local field = { mapId = nil, w = 0, h = 0, dist = nil, at = nil, age = 0 }
|
||||||
|
|
||||||
|
local REBUILD_EVERY = 0.28
|
||||||
|
|
||||||
|
local function passable(map, cx, cy)
|
||||||
|
if not map:inBounds(cx, cy) then return false end
|
||||||
|
if not map:isWalkableCell(cx, cy) then return false end
|
||||||
|
-- a warp cell is walkable but standing on one takes the warp; mobs may
|
||||||
|
-- cross them (that IS the door they follow you through) so they stay in
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fixed order, so a tie between two equally good steps always breaks the
|
||||||
|
-- same way -- a mob that dithers between two cells reads as broken, and a
|
||||||
|
-- pairs() walk over a hash would give a different answer every run
|
||||||
|
local DIRS = { "right", "left", "down", "up" }
|
||||||
|
local DX = { right = 1, left = -1, down = 0, up = 0 }
|
||||||
|
local DY = { right = 0, left = 0, down = 1, up = -1 }
|
||||||
|
|
||||||
|
local function rebuildField(map, px, py)
|
||||||
|
local w, h = map.widthCells, map.heightCells
|
||||||
|
local dist = {}
|
||||||
|
-- a plain array queue: BFS on a grid never revisits a cell, so no heap
|
||||||
|
-- and no priority is needed and the whole sweep is one pass
|
||||||
|
local qx, qy = { px }, { py }
|
||||||
|
local head = 1
|
||||||
|
dist[py * w + px] = 0
|
||||||
|
while head <= #qx do
|
||||||
|
local cx, cy = qx[head], qy[head]
|
||||||
|
head = head + 1
|
||||||
|
local d = dist[cy * w + cx] + 1
|
||||||
|
for i = 1, 4 do
|
||||||
|
local dir = DIRS[i]
|
||||||
|
local nx, ny = cx + DX[dir], cy + DY[dir]
|
||||||
|
local key = ny * w + nx
|
||||||
|
if dist[key] == nil and passable(map, nx, ny) then
|
||||||
|
dist[key] = d
|
||||||
|
qx[#qx + 1], qy[#qy + 1] = nx, ny
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
field.mapId, field.w, field.h, field.dist = map.id, w, h, dist
|
||||||
|
field.at = { px, py }
|
||||||
|
field.age = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function distAt(cx, cy)
|
||||||
|
if not field.dist then return nil end
|
||||||
|
if cx < 0 or cy < 0 or cx >= field.w or cy >= field.h then return nil end
|
||||||
|
return field.dist[cy * field.w + cx]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- named for the suite: the sweep, and the distance it wrote to a cell
|
||||||
|
HordeMobs._dist = distAt
|
||||||
|
HordeMobs._rebuild = rebuildField
|
||||||
|
|
||||||
|
-- ------- spawning
|
||||||
|
|
||||||
|
local function liveSprites(G)
|
||||||
|
local out = {}
|
||||||
|
local sprites = G and G.data and G.data.sprites
|
||||||
|
for _, key in ipairs(CAST) do
|
||||||
|
if sprites and sprites[key] then out[#out + 1] = key end
|
||||||
|
end
|
||||||
|
if #out == 0 and sprites then
|
||||||
|
-- a total conversion with none of the vanilla sheets: take whatever
|
||||||
|
-- walker it does have rather than spawning nothing at all
|
||||||
|
local keys = {}
|
||||||
|
for key, def in pairs(sprites) do
|
||||||
|
if def and def.walker then keys[#keys + 1] = key end
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
for i = 1, math.min(6, #keys) do out[i] = keys[i] end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cells at a fair distance from the player that the flow field says are
|
||||||
|
-- actually reachable, preferring the far end of the band so the horde
|
||||||
|
-- arrives from off in the dark rather than on top of you.
|
||||||
|
local function spawnCells(map, near, far, want)
|
||||||
|
local out = {}
|
||||||
|
if not field.dist then return out end
|
||||||
|
for key, d in pairs(field.dist) do
|
||||||
|
if d >= near and d <= far then
|
||||||
|
local cy = math.floor(key / field.w)
|
||||||
|
local cx = key - cy * field.w
|
||||||
|
out[#out + 1] = { cx, cy, d }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- shuffle, then bias toward distance: sorting outright would file every
|
||||||
|
-- mob in from the same corner
|
||||||
|
for i = #out, 2, -1 do
|
||||||
|
local j = love.math.random(i)
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b) return a[3] > b[3] end)
|
||||||
|
while #out > (want or 16) do table.remove(out) end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function occupiedCell(state, cx, cy)
|
||||||
|
local Collision = require("src.world.Collision")
|
||||||
|
return Collision.occupied(state.entities, cx, cy, nil) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One mob, on a cell, on the live map. Returns the roster entry or nil.
|
||||||
|
local function spawnAt(G, state, cx, cy, wave)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return nil end
|
||||||
|
local sprites = liveSprites(G)
|
||||||
|
if #sprites == 0 then return nil end
|
||||||
|
local def = {
|
||||||
|
x = cx, y = cy,
|
||||||
|
sprite = sprites[love.math.random(#sprites)],
|
||||||
|
movement = "STAY",
|
||||||
|
range = "DOWN",
|
||||||
|
name = "HORDE",
|
||||||
|
hordeMob = true,
|
||||||
|
}
|
||||||
|
local mapId = state.map.id
|
||||||
|
local okAdd, npcId = pcall(state.addRuntimeObject, state, mapId, def, OWNER)
|
||||||
|
if not (okAdd and npcId) then return nil end
|
||||||
|
s.spawned[mapId] = s.spawned[mapId] or {}
|
||||||
|
s.spawned[mapId][def.index] = true
|
||||||
|
|
||||||
|
local npc = nil
|
||||||
|
for _, e in ipairs(state.npcs) do
|
||||||
|
if e.id == npcId then npc = e break end
|
||||||
|
end
|
||||||
|
if not npc then return nil end
|
||||||
|
npc.wanders = false
|
||||||
|
npc.stepFrames = stepFrames(wave)
|
||||||
|
local entry = {
|
||||||
|
npc = npc, id = npcId, mapId = mapId,
|
||||||
|
hp = mobHp(wave), attackT = 0,
|
||||||
|
}
|
||||||
|
s.mobs[#s.mobs + 1] = entry
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- removal
|
||||||
|
--
|
||||||
|
-- Targeted, because the engine's own removeRuntimeObject walks every map
|
||||||
|
-- in the game to find one object and a firefight calls this several times
|
||||||
|
-- a second.
|
||||||
|
|
||||||
|
local function dropNpc(state, npcId)
|
||||||
|
for _, list in ipairs({ state.npcs or {}, state.entities or {} }) do
|
||||||
|
for i = #list, 1, -1 do
|
||||||
|
if list[i].id == npcId then table.remove(list, i) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if state.npcPool then state.npcPool[npcId] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Take this mode's objects back out of a map record. Runtime objects live
|
||||||
|
-- in Game.data.maps[id].objects until removed, and setMap respawns from
|
||||||
|
-- that list -- so a def left behind is a mob waiting on the far side of a
|
||||||
|
-- door long after the mode ended.
|
||||||
|
local function scrubMap(G, mapId, indices)
|
||||||
|
local def = G and G.data and G.data.maps and G.data.maps[mapId]
|
||||||
|
if not def or not def.objects then return end
|
||||||
|
for i = #def.objects, 1, -1 do
|
||||||
|
local obj = def.objects[i]
|
||||||
|
if obj and obj.hordeMob and (not indices or indices[obj.index]) then
|
||||||
|
table.remove(def.objects, i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the roster's own step
|
||||||
|
|
||||||
|
local function faceToward(npc, cx, cy)
|
||||||
|
local dx, dy = cx - npc.cellX, cy - npc.cellY
|
||||||
|
if math.abs(dx) > math.abs(dy) then
|
||||||
|
return dx > 0 and "right" or "left"
|
||||||
|
end
|
||||||
|
return dy > 0 and "down" or "up"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Walk one mob downhill on the flow field. The best neighbour is the one
|
||||||
|
-- with the lowest distance; when it is taken, the second best is tried,
|
||||||
|
-- and when both are taken the mob waits a beat -- which is what makes a
|
||||||
|
-- pack pool around the player instead of queueing behind one another.
|
||||||
|
local function stepMob(state, entry)
|
||||||
|
local npc = entry.npc
|
||||||
|
if npc.moving then return end
|
||||||
|
local here = distAt(npc.cellX, npc.cellY)
|
||||||
|
-- close enough: stand and swing rather than crowding into the lens
|
||||||
|
if here and here <= STANDOFF then
|
||||||
|
local p = state.player
|
||||||
|
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local best, bestD, second, secondD = nil, nil, nil, nil
|
||||||
|
for i = 1, 4 do
|
||||||
|
local dir = DIRS[i]
|
||||||
|
local tx, ty = npc.cellX + DX[dir], npc.cellY + DY[dir]
|
||||||
|
local d = distAt(tx, ty)
|
||||||
|
-- the standoff is enforced on the cell being ENTERED, not the one
|
||||||
|
-- being stood on: a mob that checked only where it was would still
|
||||||
|
-- finish the step it was already taking and end up in the lens
|
||||||
|
if d and d < STANDOFF then d = nil end
|
||||||
|
if d and (not here or d < here) then
|
||||||
|
if not bestD or d < bestD then
|
||||||
|
second, secondD = best, bestD
|
||||||
|
best, bestD = { dir, tx, ty }, d
|
||||||
|
elseif not secondD or d < secondD then
|
||||||
|
second, secondD = { dir, tx, ty }, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, pick in ipairs({ best, second }) do
|
||||||
|
if pick then
|
||||||
|
local dir, tx, ty = pick[1], pick[2], pick[3]
|
||||||
|
if not occupiedCell(state, tx, ty) then
|
||||||
|
npc.facing = dir
|
||||||
|
npc.targetX, npc.targetY = tx, ty
|
||||||
|
npc.moving = true
|
||||||
|
npc.progress = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- boxed in: keep facing the player so the pack still reads as a threat
|
||||||
|
local p = state.player
|
||||||
|
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the public surface
|
||||||
|
|
||||||
|
function HordeMobs.begin(G)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
local state = G and G.overworld
|
||||||
|
if not (state and state.map) then return end
|
||||||
|
field.mapId = nil
|
||||||
|
s.wave, s.waveRemaining, s.waveGap, s.spawnGap = 0, 0, 0, 0
|
||||||
|
HordeMobs.convertLocals(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Everyone already standing on the map joins in. Their sprite, their
|
||||||
|
-- position, their business -- now walking at the player. Nothing is
|
||||||
|
-- stored to undo it, because the restore warps through setMap, which
|
||||||
|
-- rebuilds every one of them from the map record (see Horde.finish).
|
||||||
|
function HordeMobs.convertLocals(state)
|
||||||
|
local s = Horde.session
|
||||||
|
if not (s and state and state.npcs) then return end
|
||||||
|
local known = {}
|
||||||
|
for _, e in ipairs(s.mobs) do known[e.npc] = true end
|
||||||
|
for _, npc in ipairs(state.npcs) do
|
||||||
|
if not known[npc] and not npc.passable then
|
||||||
|
npc.wanders = false
|
||||||
|
npc.frozen = false
|
||||||
|
npc.stepFrames = stepFrames(math.max(1, s.wave))
|
||||||
|
s.mobs[#s.mobs + 1] = {
|
||||||
|
npc = npc, id = npc.id, mapId = state.map.id,
|
||||||
|
hp = mobHp(math.max(1, s.wave)), attackT = 0, local_ = true,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeMobs.nextWave(G)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
s.wave = s.wave + 1
|
||||||
|
s.waveRemaining = waveSize(s.wave)
|
||||||
|
s.spawnGap = 0
|
||||||
|
Horde.banner(("WAVE %d"):format(s.wave), 1.6)
|
||||||
|
HordeSfx.play(HordeSfx.WAVE)
|
||||||
|
for _, e in ipairs(s.mobs) do
|
||||||
|
e.npc.stepFrames = stepFrames(s.wave)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A mob took a bullet. Returns "kill", "hit", or nil.
|
||||||
|
function HordeMobs.hit(entry, damage)
|
||||||
|
local s = Horde.session
|
||||||
|
if not (s and entry) then return nil end
|
||||||
|
entry.hp = entry.hp - (damage or 1)
|
||||||
|
if entry.hp > 0 then
|
||||||
|
HordeSfx.play(HordeSfx.HIT)
|
||||||
|
return "hit"
|
||||||
|
end
|
||||||
|
entry.dead = true
|
||||||
|
s.kills = s.kills + 1
|
||||||
|
Horde.addScore(killScore(math.max(1, s.wave)))
|
||||||
|
HordeSfx.randomCry()
|
||||||
|
return "kill"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every live mob, for the gun's ray to test against.
|
||||||
|
function HordeMobs.list()
|
||||||
|
local s = Horde.session
|
||||||
|
return s and s.mobs or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function HordeMobs.update(dt, G)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
local state = G and G.overworld
|
||||||
|
if not (state and state.map and state.player) then return end
|
||||||
|
local p = state.player
|
||||||
|
|
||||||
|
-- the flow field, rebuilt on a clock and whenever the player changes
|
||||||
|
-- cell far enough that the old numbers point at where they used to be
|
||||||
|
field.age = field.age + dt
|
||||||
|
local moved = field.at
|
||||||
|
and (math.abs(field.at[1] - p.cellX) + math.abs(field.at[2] - p.cellY)) or 99
|
||||||
|
if field.mapId ~= state.map.id or field.age >= REBUILD_EVERY or moved >= 2 then
|
||||||
|
rebuildField(state.map, p.cellX, p.cellY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the dead, collected before anything walks
|
||||||
|
for i = #s.mobs, 1, -1 do
|
||||||
|
local e = s.mobs[i]
|
||||||
|
if e.dead or not e.npc then
|
||||||
|
if e.npc then dropNpc(state, e.id) end
|
||||||
|
table.remove(s.mobs, i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the living
|
||||||
|
local pcx, pcy = p.px + 8, p.py + 8
|
||||||
|
for _, e in ipairs(s.mobs) do
|
||||||
|
local npc = e.npc
|
||||||
|
e.attackT = math.max(0, e.attackT - dt)
|
||||||
|
stepMob(state, e)
|
||||||
|
local dx, dz = (npc.px + 8) - pcx, (npc.py + 8) - pcy
|
||||||
|
if dx * dx + dz * dz <= REACH * REACH then
|
||||||
|
if e.attackT <= 0 then
|
||||||
|
e.attackT = 0.8
|
||||||
|
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||||
|
Horde.damage()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not Horde.playing() then return end
|
||||||
|
|
||||||
|
-- the crowd that followed the player through a door, arriving
|
||||||
|
if s.followQueue > 0 then
|
||||||
|
s.spawnGap = s.spawnGap - dt
|
||||||
|
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||||
|
s.spawnGap = FOLLOW_INTERVAL
|
||||||
|
local cells = spawnCells(state.map, 2, 9, 8)
|
||||||
|
local cell = cells[1]
|
||||||
|
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||||
|
s.followQueue = s.followQueue - 1
|
||||||
|
else
|
||||||
|
s.followQueue = s.followQueue - 1 -- nowhere to put them; let it go
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the wave itself
|
||||||
|
if s.waveRemaining > 0 then
|
||||||
|
s.spawnGap = s.spawnGap - dt
|
||||||
|
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||||
|
s.spawnGap = SPAWN_INTERVAL
|
||||||
|
local cells = spawnCells(state.map, 7, 18, 10)
|
||||||
|
if #cells == 0 then cells = spawnCells(state.map, 3, 30, 10) end
|
||||||
|
local cell = cells[1]
|
||||||
|
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||||
|
s.waveRemaining = s.waveRemaining - 1
|
||||||
|
else
|
||||||
|
s.spawnGap = 1.5 -- no room right now; try again shortly
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif #s.mobs == 0 then
|
||||||
|
s.waveGap = s.waveGap + dt
|
||||||
|
if s.waveGap == dt then
|
||||||
|
Horde.addScore(waveBonus(s.wave))
|
||||||
|
Horde.banner(("WAVE %d CLEAR"):format(s.wave), 1.8)
|
||||||
|
end
|
||||||
|
if s.waveGap >= WAVE_GAP then
|
||||||
|
s.waveGap = 0
|
||||||
|
HordeMobs.nextWave(G)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the door
|
||||||
|
--
|
||||||
|
-- map.entered fires after setMap has rebuilt the world, which means every
|
||||||
|
-- mob instance from the old map is already gone. What is left to do is
|
||||||
|
-- take our defs off the old map (or they respawn if the player ever comes
|
||||||
|
-- back), remember how many were chasing, and let update() walk them in.
|
||||||
|
|
||||||
|
function HordeMobs.onMapEntered(payload)
|
||||||
|
local s = Horde.session
|
||||||
|
if not s then return end
|
||||||
|
local G = require("src.core.Game")
|
||||||
|
local state = G.overworld
|
||||||
|
if not (state and state.map) then return end
|
||||||
|
local newId = state.map.id
|
||||||
|
|
||||||
|
local following = 0
|
||||||
|
for _, e in ipairs(s.mobs) do
|
||||||
|
if e.mapId ~= newId and not e.local_ then following = following + 1 end
|
||||||
|
end
|
||||||
|
-- the old map's records, and any instance the pool kept
|
||||||
|
for mapId, indices in pairs(s.spawned) do
|
||||||
|
if mapId ~= newId then
|
||||||
|
scrubMap(G, mapId, indices)
|
||||||
|
s.spawned[mapId] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i = #s.mobs, 1, -1 do
|
||||||
|
if s.mobs[i].mapId ~= newId then table.remove(s.mobs, i) end
|
||||||
|
end
|
||||||
|
|
||||||
|
field.mapId = nil
|
||||||
|
s.followQueue = math.max(s.followQueue, following)
|
||||||
|
s.spawnGap = math.min(s.spawnGap, 0.4)
|
||||||
|
HordeMobs.convertLocals(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the end
|
||||||
|
--
|
||||||
|
-- Every def this mode wrote, off every map it wrote one to. The live
|
||||||
|
-- instances go too, though the restore's own warp would have taken them:
|
||||||
|
-- cleanup has to leave a consistent world even when it is called from a
|
||||||
|
-- path that never warps.
|
||||||
|
|
||||||
|
function HordeMobs.cleanup(G)
|
||||||
|
G = G or require("src.core.Game")
|
||||||
|
local s = Horde.session
|
||||||
|
local state = G.overworld
|
||||||
|
if s then
|
||||||
|
for _, e in ipairs(s.mobs) do
|
||||||
|
if state and not e.local_ then dropNpc(state, e.id) end
|
||||||
|
end
|
||||||
|
for mapId, indices in pairs(s.spawned) do
|
||||||
|
scrubMap(G, mapId, indices)
|
||||||
|
end
|
||||||
|
s.mobs, s.spawned = {}, {}
|
||||||
|
s.followQueue, s.waveRemaining = 0, 0
|
||||||
|
else
|
||||||
|
-- a session that vanished under us (a reload mid-mode): sweep every
|
||||||
|
-- map for this mode's marker rather than leaving actors behind
|
||||||
|
for mapId in pairs((G.data and G.data.maps) or {}) do
|
||||||
|
scrubMap(G, mapId, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
field.mapId, field.dist, field.at = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- named for the suite, down here because the walk is defined above it
|
||||||
|
HordeMobs._stepMob = stepMob
|
||||||
|
|
||||||
|
return HordeMobs
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
-- HORDE MODE: the gun, in Game Boy hardware.
|
||||||
|
--
|
||||||
|
-- Every sound this mode makes is SYNTHESIZED on the same emulated APU the
|
||||||
|
-- rest of the game speaks through -- no sample files ship with the mod.
|
||||||
|
-- That is a deliberate aesthetic choice as much as a legal one: Lavender
|
||||||
|
-- Town is playing, the cries are the real cries, and a 44kHz foley
|
||||||
|
-- gunshot dropped on top would read as a different program running in the
|
||||||
|
-- same window. Authored here with ChipAsm (src/audio/ChipAsm.lua), which
|
||||||
|
-- assembles note tables into the channel bytecode ChipAudio interprets.
|
||||||
|
--
|
||||||
|
-- WHAT A GUNSHOT IS, on this hardware. Channel 4 is a noise generator
|
||||||
|
-- whose `parameter` byte is NR43: the high nibble is the shift clock (LOW
|
||||||
|
-- values are BRIGHT, high values are low rumble), bit 3 picks the short
|
||||||
|
-- 7-bit LFSR (metallic and pitched) over the long 15-bit one (white
|
||||||
|
-- hiss), and the low three bits divide. A real gunshot is a bright crack
|
||||||
|
-- collapsing into a body and then a room tail, so each sound here is a
|
||||||
|
-- STAGED program: three or four noise notes marching down the parameter
|
||||||
|
-- byte, each shorter-lived than the last. `len` is in frames of 1/60s,
|
||||||
|
-- `volume` is 0-15, and `fade` is the envelope period -- 1 decays fastest,
|
||||||
|
-- 7 slowest, 0 holds for the note's whole length.
|
||||||
|
--
|
||||||
|
-- The shot also gets two frames of channel 1 underneath it: a square note
|
||||||
|
-- swept hard downward, which is the only way to put a low thump on this
|
||||||
|
-- chip. It costs the music its lead channel for 1/30s per shot, which is
|
||||||
|
-- inaudible as interference and is most of what makes the shot feel like
|
||||||
|
-- it has weight.
|
||||||
|
--
|
||||||
|
-- THREE SHOT VARIANTS, round-robined. Sound.play caches ONE Source per
|
||||||
|
-- registered name and restarts it (stop then play), so firing twice on
|
||||||
|
-- one name cuts the first shot's tail off. Three names means three
|
||||||
|
-- Sources, so a fast trigger finger overlaps its own echoes the way a
|
||||||
|
-- real one does -- and the variants differ slightly in their tails, which
|
||||||
|
-- takes the machine-gun sameness off a repeated sound.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local HordeSfx = {}
|
||||||
|
|
||||||
|
-- the registered names, in the shape the rest of the mode asks for them
|
||||||
|
HordeSfx.SHOTS = { "DS_HORDE_SHOT_1", "DS_HORDE_SHOT_2", "DS_HORDE_SHOT_3" }
|
||||||
|
HordeSfx.DRY = "DS_HORDE_DRY"
|
||||||
|
HordeSfx.MAG_OUT = "DS_HORDE_MAG_OUT"
|
||||||
|
HordeSfx.MAG_IN = "DS_HORDE_MAG_IN"
|
||||||
|
HordeSfx.RACK = "DS_HORDE_RACK"
|
||||||
|
HordeSfx.HIT = "DS_HORDE_HIT"
|
||||||
|
HordeSfx.HURT = "DS_HORDE_HURT"
|
||||||
|
HordeSfx.WAVE = "DS_HORDE_WAVE"
|
||||||
|
|
||||||
|
-- ------- the programs
|
||||||
|
|
||||||
|
-- The shot's noise stage list: bright crack, body, tail, room. `tail`
|
||||||
|
-- lets the three variants differ in how the last stage rings out without
|
||||||
|
-- restating the whole program.
|
||||||
|
local function shotNoise(tail)
|
||||||
|
return {
|
||||||
|
-- the crack: one frame, full volume, brightest parameter the chip has
|
||||||
|
{ noiseNote = { len = 1, volume = 15, fade = 1, parameter = 0x00 } },
|
||||||
|
-- the body: the shift clock drops, the 7-bit LFSR gives it a metallic
|
||||||
|
-- edge -- this is the part that reads as "a mechanism did that"
|
||||||
|
{ noiseNote = { len = 2, volume = 13, fade = 2, parameter = 0x2C } },
|
||||||
|
-- the tail: lower, softer, longer
|
||||||
|
{ noiseNote = { len = 3, volume = 8, fade = 3, parameter = tail[1] } },
|
||||||
|
-- the room: a low breath of noise fading under everything
|
||||||
|
{ noiseNote = { len = tail[2], volume = 4, fade = 4, parameter = tail[3] } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The thump under the crack: channel 1's frequency register swept down
|
||||||
|
-- hard. 0x600 is around 250Hz; the sweep drags it into the floor over the
|
||||||
|
-- two frames it lives, which is a kick drum by another name.
|
||||||
|
local THUMP = {
|
||||||
|
{ pitchSweep = { pace = 2, subtract = true, shift = 3 } },
|
||||||
|
{ squareNote = { len = 2, volume = 12, fade = 2, frequency = 0x600 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function shot(tail)
|
||||||
|
return {
|
||||||
|
channels = {
|
||||||
|
{ hw = 1, program = THUMP },
|
||||||
|
{ hw = 4, program = shotNoise(tail) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The reload, in three separate sounds the gun fires on its own clock:
|
||||||
|
-- the magazine dropping out, the fresh one seating, and the slide coming
|
||||||
|
-- back and going home. Noise only -- these are mechanical clicks, and
|
||||||
|
-- keeping them off the tone channels leaves the music alone.
|
||||||
|
local PROGRAMS = {
|
||||||
|
[HordeSfx.SHOTS[1]] = shot({ 0x55, 5, 0x76 }),
|
||||||
|
[HordeSfx.SHOTS[2]] = shot({ 0x54, 6, 0x77 }),
|
||||||
|
[HordeSfx.SHOTS[3]] = shot({ 0x65, 4, 0x86 }),
|
||||||
|
|
||||||
|
-- the hammer falling on nothing: one dull tick, no tail
|
||||||
|
[HordeSfx.DRY] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 1, volume = 7, fade = 1, parameter = 0x38 } },
|
||||||
|
{ noiseNote = { len = 1, volume = 3, fade = 1, parameter = 0x54 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- the magazine leaving: a click and a soft drop away from it
|
||||||
|
[HordeSfx.MAG_OUT] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 1, volume = 10, fade = 1, parameter = 0x1A } },
|
||||||
|
{ noiseNote = { len = 2, volume = 5, fade = 2, parameter = 0x58 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- the fresh magazine seating: a firmer, lower clack with a bit of body
|
||||||
|
[HordeSfx.MAG_IN] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 1, volume = 13, fade = 1, parameter = 0x18 } },
|
||||||
|
{ noiseNote = { len = 2, volume = 8, fade = 2, parameter = 0x46 } },
|
||||||
|
{ noiseNote = { len = 2, volume = 3, fade = 3, parameter = 0x67 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- the slide: back (bright scrape), a frame of nothing, then home (hard)
|
||||||
|
[HordeSfx.RACK] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 2, volume = 9, fade = 2, parameter = 0x25 } },
|
||||||
|
{ rest = 1 },
|
||||||
|
{ noiseNote = { len = 1, volume = 14, fade = 1, parameter = 0x11 } },
|
||||||
|
{ noiseNote = { len = 2, volume = 6, fade = 2, parameter = 0x44 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- a bullet arriving: short, bright, gone -- the hit marker's own sound
|
||||||
|
[HordeSfx.HIT] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 1, volume = 11, fade = 1, parameter = 0x14 } },
|
||||||
|
{ noiseNote = { len = 1, volume = 5, fade = 2, parameter = 0x42 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- being hit: a low ugly thud on the noise channel with a square groan
|
||||||
|
-- under it, sweeping DOWN -- the sound of losing something
|
||||||
|
[HordeSfx.HURT] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 1, program = {
|
||||||
|
{ pitchSweep = { pace = 3, subtract = true, shift = 4 } },
|
||||||
|
{ squareNote = { len = 6, volume = 11, fade = 3, frequency = 0x480 } },
|
||||||
|
} },
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 2, volume = 12, fade = 2, parameter = 0x66 } },
|
||||||
|
{ noiseNote = { len = 4, volume = 6, fade = 3, parameter = 0x78 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- a wave arriving: two rising square stabs, deliberately not a fanfare
|
||||||
|
[HordeSfx.WAVE] = {
|
||||||
|
channels = {
|
||||||
|
{ hw = 1, program = {
|
||||||
|
{ squareNote = { len = 3, volume = 10, fade = 2, frequency = 0x5C0 } },
|
||||||
|
{ rest = 1 },
|
||||||
|
{ squareNote = { len = 6, volume = 12, fade = 3, frequency = 0x680 } },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------- registration
|
||||||
|
|
||||||
|
-- Assemble every program and put it in the sfx registry. Called once from
|
||||||
|
-- main.lua at load. A malformed note table raises inside ChipAsm; each is
|
||||||
|
-- assembled under pcall so one bad program is one missing sound rather
|
||||||
|
-- than a mod that fails to load.
|
||||||
|
function HordeSfx.register(mod)
|
||||||
|
local ok, ChipAsm = pcall(require, "src.audio.ChipAsm")
|
||||||
|
if not (ok and ChipAsm) then return false end
|
||||||
|
local n = 0
|
||||||
|
for name, spec in pairs(PROGRAMS) do
|
||||||
|
local built, out = pcall(ChipAsm.sfx, spec)
|
||||||
|
if built and out and out.chip then
|
||||||
|
local reg = pcall(function()
|
||||||
|
mod.content.sfx:register(name, { chip = out.chip })
|
||||||
|
end)
|
||||||
|
if reg then n = n + 1 end
|
||||||
|
elseif mod.log then
|
||||||
|
mod.log:error("horde: sfx %s did not assemble: %s", name, tostring(out))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- playback
|
||||||
|
--
|
||||||
|
-- One indirection so callers never touch Sound directly and a headless
|
||||||
|
-- run (no love.audio) costs a pcall rather than an error.
|
||||||
|
|
||||||
|
local function play(name)
|
||||||
|
pcall(function()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
require("src.core.Sound").play(Game.data, name)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
HordeSfx.play = play
|
||||||
|
|
||||||
|
local shotIndex = 0
|
||||||
|
|
||||||
|
-- The next shot in the round-robin, so consecutive rounds overlap rather
|
||||||
|
-- than cutting each other off (see the header).
|
||||||
|
function HordeSfx.shot()
|
||||||
|
shotIndex = shotIndex % #HordeSfx.SHOTS + 1
|
||||||
|
play(HordeSfx.SHOTS[shotIndex])
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the cries
|
||||||
|
--
|
||||||
|
-- Every mob that dies screams as something from the national dex. The
|
||||||
|
-- list is built once from the live cry registry -- whatever the game and
|
||||||
|
-- whatever mods are loaded have between them -- so this needs no data of
|
||||||
|
-- its own and picks up a total conversion's roster for free.
|
||||||
|
|
||||||
|
local cryList = nil
|
||||||
|
|
||||||
|
local function cries()
|
||||||
|
if cryList then return cryList end
|
||||||
|
local out = {}
|
||||||
|
pcall(function()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local table_ = Game.data and Game.data.audio and Game.data.audio.cries
|
||||||
|
for species in pairs(table_ or {}) do out[#out + 1] = species end
|
||||||
|
end)
|
||||||
|
table.sort(out) -- love.math.random over a stable order, not hash order
|
||||||
|
cryList = out
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A random cry, at a random-ish pitch. Nothing is more Pokemon than the
|
||||||
|
-- wrong animal noise coming out of a man in a suit.
|
||||||
|
function HordeSfx.randomCry()
|
||||||
|
local list = cries()
|
||||||
|
if #list == 0 then return nil end
|
||||||
|
local species = list[love.math.random(#list)]
|
||||||
|
pcall(function()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
require("src.core.Sound").playCry(Game.data, species)
|
||||||
|
end)
|
||||||
|
return species
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a fresh boot (or a hot reload) rebuilds the species list
|
||||||
|
function HordeSfx.invalidate()
|
||||||
|
cryList = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return HordeSfx
|
||||||
@@ -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
|
||||||
+44
-3
@@ -6,9 +6,11 @@
|
|||||||
-- here -- translation in the fourth column, m[4]/m[8]/m[12].
|
-- here -- translation in the fourth column, m[4]/m[8]/m[12].
|
||||||
--
|
--
|
||||||
-- Only what the renderer actually needs: a perspective projection (the
|
-- Only what the renderer actually needs: a perspective projection (the
|
||||||
-- camera), an orthographic one (the sun's shadow pass), a look-based view,
|
-- camera), an orthographic one (the sun's shadow pass), an asymmetric one
|
||||||
-- and the translate/rotateY/scale a model matrix is built from. No general
|
-- (a headset's per-eye frustum), a look-based view, a quaternion rotation
|
||||||
-- inverse, no quaternions.
|
-- (a headset's pose), and the translate/rotateY/scale a model matrix is
|
||||||
|
-- built from. No general inverse -- the VR view inverts its rigid pieces
|
||||||
|
-- one at a time.
|
||||||
|
|
||||||
local Mat4 = {}
|
local Mat4 = {}
|
||||||
|
|
||||||
@@ -62,6 +64,45 @@ function Mat4.rotateX(a)
|
|||||||
0, 0, 0, 1 }
|
0, 0, 0, 1 }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The rotation a unit quaternion describes, row-major. The VR rig is what
|
||||||
|
-- needs it: an OpenXR eye pose arrives as position + orientation
|
||||||
|
-- quaternion, and both the eye's transform and its inverse (the view) are
|
||||||
|
-- built from this.
|
||||||
|
function Mat4.fromQuat(x, y, z, w)
|
||||||
|
local xx, yy, zz = x * x, y * y, z * z
|
||||||
|
local xy, xz, yz = x * y, x * z, y * z
|
||||||
|
local wx, wy, wz = w * x, w * y, w * z
|
||||||
|
return { 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy), 0,
|
||||||
|
2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx), 0,
|
||||||
|
2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy), 0,
|
||||||
|
0, 0, 0, 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Transpose. For a pure rotation this IS the inverse, which is how the VR
|
||||||
|
-- view matrix is assembled without a general 4x4 inverse.
|
||||||
|
function Mat4.transpose(m)
|
||||||
|
return { m[1], m[5], m[9], m[13],
|
||||||
|
m[2], m[6], m[10], m[14],
|
||||||
|
m[3], m[7], m[11], m[15],
|
||||||
|
m[4], m[8], m[12], m[16] }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Right-handed perspective from an OpenXR-style asymmetric field of view:
|
||||||
|
-- four signed HALF-ANGLES off the view axis (left and down negative), onto
|
||||||
|
-- GL clip space (z in [-1, 1]). A headset's per-eye frustum is off-centre
|
||||||
|
-- -- the nose side is narrower than the temple side -- so the symmetric
|
||||||
|
-- perspective() above cannot express it.
|
||||||
|
function Mat4.fovProjection(angleLeft, angleRight, angleUp, angleDown,
|
||||||
|
near, far)
|
||||||
|
local l, r = math.tan(angleLeft), math.tan(angleRight)
|
||||||
|
local u, d = math.tan(angleUp), math.tan(angleDown)
|
||||||
|
local w, h, dz = r - l, u - d, near - far
|
||||||
|
return { 2 / w, 0, (r + l) / w, 0,
|
||||||
|
0, 2 / h, (u + d) / h, 0,
|
||||||
|
0, 0, (far + near) / dz, (2 * far * near) / dz,
|
||||||
|
0, 0, -1, 0 }
|
||||||
|
end
|
||||||
|
|
||||||
-- Right-handed perspective onto GL clip space (z in [-1, 1]).
|
-- Right-handed perspective onto GL clip space (z in [-1, 1]).
|
||||||
function Mat4.perspective(fovY, aspect, near, far)
|
function Mat4.perspective(fovY, aspect, near, far)
|
||||||
local f = 1 / math.tan(fovY / 2)
|
local f = 1 / math.tan(fovY / 2)
|
||||||
|
|||||||
+691
-19
@@ -68,10 +68,81 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
|
|||||||
OverworldBattle.LABEL,
|
OverworldBattle.LABEL,
|
||||||
{ true, false }, { "ON", "OFF" })
|
{ true, false }, { "ON", "OFF" })
|
||||||
|
|
||||||
|
-- Whether the VR row is ON -- read lazily, because VR requires modules
|
||||||
|
-- that sit above this one. While it is, this mode stops being optional:
|
||||||
|
-- the headset's battle seat, the pokedex screen and the effects plane
|
||||||
|
-- all assume a fight standing on the world, and a white-field battle
|
||||||
|
-- inside a headset is exactly the flat screen VR exists to replace.
|
||||||
|
local function vrOn()
|
||||||
|
local ok, vr = pcall(V.require, "VR")
|
||||||
|
return ok and vr and vr.enabled and vr.enabled() or false
|
||||||
|
end
|
||||||
|
|
||||||
function OverworldBattle.enabled()
|
function OverworldBattle.enabled()
|
||||||
|
if vrOn() then return true end
|
||||||
return OverworldBattle.setting:get() and true or false
|
return OverworldBattle.setting:get() and true or false
|
||||||
end
|
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. And held OFF under VR: the
|
||||||
|
-- headset stands both mons on the world -- a flat back pic pinned to the
|
||||||
|
-- 2D frame would keep your own mon off the arena the battle seat looks at.
|
||||||
|
function OverworldBattle.backPinned()
|
||||||
|
if not OverworldBattle.enabled() then return false end
|
||||||
|
if vrOn() 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
|
-- ------- both mons face you
|
||||||
--
|
--
|
||||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||||
@@ -81,6 +152,10 @@ end
|
|||||||
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
-- 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.
|
-- 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
|
-- 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
|
-- 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
|
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
||||||
@@ -91,6 +166,7 @@ local staged = { mapId = nil, ok = false }
|
|||||||
|
|
||||||
function OverworldBattle.wantsFront()
|
function OverworldBattle.wantsFront()
|
||||||
if not OverworldBattle.enabled() then return false end
|
if not OverworldBattle.enabled() then return false end
|
||||||
|
if OverworldBattle.backPinned() then return false end
|
||||||
if not Voxel3D.available() then return false end
|
if not Voxel3D.available() then return false end
|
||||||
-- required here rather than through the file's own helper: this runs
|
-- 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
|
-- while a battler is being built, which is before that helper is defined
|
||||||
@@ -140,16 +216,131 @@ OverworldBattle.HUD_RECT = {
|
|||||||
player = { 72, 56, 88, 40 },
|
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
|
-- ------- the live battle
|
||||||
--
|
--
|
||||||
-- nil when no overworld battle is running. Never more than one: battles do
|
-- nil when no overworld battle is running. Never more than one: battles do
|
||||||
-- not nest.
|
-- not nest.
|
||||||
local session = nil
|
local session = nil
|
||||||
|
|
||||||
|
local function isIOS()
|
||||||
|
return love.system and love.system.getOS and love.system.getOS() == "iOS"
|
||||||
|
end
|
||||||
|
|
||||||
local function game()
|
local function game()
|
||||||
return require("src.core.Game")
|
return require("src.core.Game")
|
||||||
end
|
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
|
-- 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.
|
-- anything that captured one before the battle still sees the same table.
|
||||||
local function restoreCast()
|
local function restoreCast()
|
||||||
@@ -174,6 +365,36 @@ local function cullCast(state)
|
|||||||
state.ghosts = {}
|
state.ghosts = {}
|
||||||
end
|
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
|
-- 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
|
-- 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
|
-- changes, so a map with no room for an arena plays exactly the vanilla
|
||||||
@@ -189,6 +410,10 @@ function OverworldBattle.begin(state, battle)
|
|||||||
state.player.surfing)
|
state.player.surfing)
|
||||||
if not (ok and arena) then return false end
|
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,
|
session = { state = state, arena = arena, battle = battle, shot = nil,
|
||||||
armed = false, token = 0 }
|
armed = false, token = 0 }
|
||||||
cullCast(state)
|
cullCast(state)
|
||||||
@@ -266,6 +491,23 @@ function OverworldBattle.update(dt)
|
|||||||
-- inside somebody else's frame means putting the frame back afterwards.
|
-- inside somebody else's frame means putting the frame back afterwards.
|
||||||
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
|
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
|
||||||
if not okTex then textures = nil end
|
if not okTex then textures = nil end
|
||||||
|
-- stashed for the VR eye pass, which stands these same pics on the map
|
||||||
|
-- in ITS view of the world (VoxelScene's eyes path). Stashed HERE
|
||||||
|
-- because rendering them binds canvases, which the eye pass -- mid-scene
|
||||||
|
-- when it wants them -- must never do; reading a stashed canvas is free.
|
||||||
|
session.textures = textures
|
||||||
|
-- and the move-animation layer, for the same eyes -- rendered only
|
||||||
|
-- while a headset is actually watching, because only the VR world
|
||||||
|
-- pass draws it (the flat screen has the animations in-frame already)
|
||||||
|
session.animTex = nil
|
||||||
|
local okVR, vrOn = pcall(function()
|
||||||
|
local vr = V.require("VR")
|
||||||
|
return vr.active and vr.active() or false
|
||||||
|
end)
|
||||||
|
if okVR and vrOn and session.battle then
|
||||||
|
local okA, anim = pcall(OverworldBattle.animTexture, session.battle)
|
||||||
|
if okA then session.animTex = anim end
|
||||||
|
end
|
||||||
session.token = (session.token or 0) + 1
|
session.token = (session.token or 0) + 1
|
||||||
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
||||||
textures, session.token)
|
textures, session.token)
|
||||||
@@ -276,11 +518,13 @@ function OverworldBattle.update(dt)
|
|||||||
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
|
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
|
||||||
-- guard, which retires a pipeline for the session.
|
-- guard, which retires a pipeline for the session.
|
||||||
session.shot = nil
|
session.shot = nil
|
||||||
|
session.snapped = false
|
||||||
session.broken = true
|
session.broken = true
|
||||||
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
|
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
|
||||||
.. "on the plain battle background", tostring(shot))
|
.. "on the plain battle background", tostring(shot))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
session.snapped = false
|
||||||
if shot and shot.canvas then
|
if shot and shot.canvas then
|
||||||
-- the depth of field is measured off the two marks: the slab in focus is
|
-- 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
|
-- the one the mons are standing in, at whatever the drift has done to
|
||||||
@@ -295,6 +539,24 @@ function OverworldBattle.update(dt)
|
|||||||
-- so a panel over a blurred far field is frosted from what is actually
|
-- so a panel over a blurred far field is frosted from what is actually
|
||||||
-- behind it
|
-- behind it
|
||||||
pcall(BattleHud.build, shot.canvas)
|
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 ios = isIOS()
|
||||||
|
local okHud, up = false, false
|
||||||
|
if not ios then
|
||||||
|
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||||
|
end
|
||||||
|
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 ios and 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
|
end
|
||||||
session.shot = shot
|
session.shot = shot
|
||||||
end
|
end
|
||||||
@@ -308,6 +570,104 @@ function OverworldBattle.shot()
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The staged fight's WORLD-side pieces, for a pass that stands the mons in
|
||||||
|
-- its own view of the map rather than in the arena's composed shot -- the
|
||||||
|
-- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed
|
||||||
|
-- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets
|
||||||
|
-- per-eye cards), the live textures table (for the hit-flash flag), and the
|
||||||
|
-- token the shadow signature keys on. nil while nothing is staged, the
|
||||||
|
-- arena is broken, or the pics have not been rendered yet.
|
||||||
|
function OverworldBattle.worldCards()
|
||||||
|
if not (session and session.arena and not session.broken) then return nil end
|
||||||
|
local tex = session.textures
|
||||||
|
if not tex then return nil end
|
||||||
|
local host = (session.state and session.state.map) or nil
|
||||||
|
if not host then return nil end
|
||||||
|
local groundY = BattleScene.groundY(host, session.arena)
|
||||||
|
return BattleScene.monCards(session.arena, groundY, tex), tex, session.token
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The live session's BATTLE STATE, once the pushed battle has been met
|
||||||
|
-- (session.battle fills in from the stack in update). The VR quad reads
|
||||||
|
-- it to tell "the battle screen is on top" from "a menu is over the
|
||||||
|
-- battle" -- the UI-only panel is right for the first and wrong for the
|
||||||
|
-- second. nil with no session, a broken one, or a battle not yet pushed.
|
||||||
|
function OverworldBattle.battle()
|
||||||
|
if not (session and not session.broken) then return nil end
|
||||||
|
return session.battle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
|
||||||
|
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
|
||||||
|
-- transparent canvas of its own. This is what stands the effects up in
|
||||||
|
-- the VR eyes' world -- see worldAnim below -- the same move the pics
|
||||||
|
-- made through sideTexture: let the engine draw what it always draws,
|
||||||
|
-- catch it on a canvas, stand the canvas in the scene.
|
||||||
|
local animLayer = nil
|
||||||
|
-- the engine's own drawAnimLayer, captured by install(). Declared HERE,
|
||||||
|
-- above the function that reads it: a local declared further down the
|
||||||
|
-- chunk would leave this function reading a global of the same name --
|
||||||
|
-- nil forever, and the effects silently absent from the eyes (the bug
|
||||||
|
-- this comment is the tombstone of).
|
||||||
|
local innerAnim = nil
|
||||||
|
|
||||||
|
function OverworldBattle.animTexture(battle)
|
||||||
|
if not (innerAnim and battle) then return nil end
|
||||||
|
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||||
|
if not animLayer then
|
||||||
|
local ok, c = pcall(love.graphics.newCanvas,
|
||||||
|
BattleScene.GB_W, BattleScene.GB_H)
|
||||||
|
if not (ok and c) then return nil end
|
||||||
|
pcall(c.setFilter, c, "nearest", "nearest")
|
||||||
|
animLayer = c
|
||||||
|
end
|
||||||
|
local g = love.graphics
|
||||||
|
local prevCanvas = g.getCanvas()
|
||||||
|
local ok = pcall(function()
|
||||||
|
g.push("all")
|
||||||
|
g.origin()
|
||||||
|
g.setCanvas(animLayer)
|
||||||
|
g.clear(0, 0, 0, 0)
|
||||||
|
g.setBlendMode("alpha")
|
||||||
|
g.setColor(1, 1, 1, 1)
|
||||||
|
innerAnim(battle, false)
|
||||||
|
g.pop()
|
||||||
|
end)
|
||||||
|
if not ok then pcall(g.pop, g) end
|
||||||
|
if prevCanvas then pcall(g.setCanvas, g, prevCanvas)
|
||||||
|
else pcall(g.setCanvas, g) end
|
||||||
|
return ok and animLayer or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The staged fight's effects, for the VR eyes: the animation layer plus
|
||||||
|
-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit
|
||||||
|
-- authored at a slot lands on the mon standing in for that slot). nil
|
||||||
|
-- while nothing is staged or no layer was rendered this frame.
|
||||||
|
function OverworldBattle.worldAnim()
|
||||||
|
if not (session and session.arena and not session.broken) then return nil end
|
||||||
|
local tex = session.animTex
|
||||||
|
if not tex then return nil end
|
||||||
|
local host = (session.state and session.state.map) or nil
|
||||||
|
if not host then return nil end
|
||||||
|
local groundY = BattleScene.groundY(host, session.arena)
|
||||||
|
local model = BattleScene.fxCard(session.arena, groundY,
|
||||||
|
OverworldBattle.ANCHOR)
|
||||||
|
if not model then return nil end
|
||||||
|
return tex, model
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Where the staged fight STANDS -- the arena and its floor height -- for a
|
||||||
|
-- camera that wants to look at it rather than draw it (the VR battle
|
||||||
|
-- mount). Answered as soon as the stage exists, textures or not: the
|
||||||
|
-- camera should be seated behind the fade before the first pic lands.
|
||||||
|
-- nil whenever no fight is staged on the world.
|
||||||
|
function OverworldBattle.stage()
|
||||||
|
if not (session and session.arena and not session.broken) then return nil end
|
||||||
|
local host = (session.state and session.state.map) or nil
|
||||||
|
if not host then return nil end
|
||||||
|
return session.arena, BattleScene.groundY(host, session.arena)
|
||||||
|
end
|
||||||
|
|
||||||
function OverworldBattle.invalidate()
|
function OverworldBattle.invalidate()
|
||||||
BattleDOF.invalidate()
|
BattleDOF.invalidate()
|
||||||
BattleHud.invalidate()
|
BattleHud.invalidate()
|
||||||
@@ -340,11 +700,21 @@ local function withoutBackgroundFill(battle, fn)
|
|||||||
if mode == "fill" and x == 0 and y == 0
|
if mode == "fill" and x == 0 and y == 0
|
||||||
and w == BattleScene.GB_W and h == BattleScene.GB_H then
|
and w == BattleScene.GB_W and h == BattleScene.GB_H then
|
||||||
local r, gr, b, a = g.getColor()
|
local r, gr, b, a = g.getColor()
|
||||||
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then
|
if r > 0.99 and gr > 0.99 and b > 0.99 then
|
||||||
local target = g.getCanvas()
|
-- Two different full-frame whites, both replaced rather than drawn.
|
||||||
if target ~= nil
|
--
|
||||||
and (target == battle.bgCanvas or target == battle.waveCanvas) then
|
-- OPAQUE is the battle's background, and on the offscreen canvases it
|
||||||
g.clear(0, 0, 0, 0)
|
-- doubles as their clear, so there it becomes a transparent one.
|
||||||
|
--
|
||||||
|
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
|
||||||
|
-- flash; over a world it whites out the map, the HUD and the text box
|
||||||
|
-- together. BattleScene puts it back on the mons alone.
|
||||||
|
if a > 0.99 then
|
||||||
|
local target = g.getCanvas()
|
||||||
|
if target ~= nil
|
||||||
|
and (target == battle.bgCanvas or target == battle.waveCanvas) then
|
||||||
|
g.clear(0, 0, 0, 0)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -356,6 +726,81 @@ local function withoutBackgroundFill(battle, fn)
|
|||||||
if not ok then error(err, 0) end
|
if not ok then error(err, 0) end
|
||||||
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 mons, as textures for the 3D pass
|
||||||
--
|
--
|
||||||
-- The two Pokemon are not composited over the world any more: they are quads
|
-- The two Pokemon are not composited over the world any more: they are quads
|
||||||
@@ -384,6 +829,9 @@ local texturing = nil
|
|||||||
|
|
||||||
local texCanvas = {}
|
local texCanvas = {}
|
||||||
local innerPics = nil -- captured by install()
|
local innerPics = nil -- captured by install()
|
||||||
|
local innerHUDs = nil -- likewise, for the snapped HUD layer
|
||||||
|
-- (innerAnim, their sibling, is declared up beside animTexture, which
|
||||||
|
-- sits earlier in the chunk than this group and must see the local)
|
||||||
|
|
||||||
local function texCanvasFor(side)
|
local function texCanvasFor(side)
|
||||||
local c = texCanvas[side]
|
local c = texCanvas[side]
|
||||||
@@ -471,15 +919,36 @@ function OverworldBattle.sideTexture(battle, side)
|
|||||||
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
|
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Whether the hit flash is showing this frame.
|
||||||
|
--
|
||||||
|
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
|
||||||
|
-- decision there (a counter plus the frame parity that makes it flicker) and
|
||||||
|
-- there is no seam that reports it. Read-only, so the worst a future engine
|
||||||
|
-- change can do is flash on a frame the engine would not have.
|
||||||
|
function OverworldBattle.flashing(battle)
|
||||||
|
local fx = battle and battle.fx
|
||||||
|
if not (fx and fx.flash and fx.flash > 0) then return false end
|
||||||
|
return (battle.frame or 0) % 4 < 2
|
||||||
|
end
|
||||||
|
|
||||||
-- Both sides, or nil when neither has anything to show.
|
-- 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)
|
function OverworldBattle.textures(battle)
|
||||||
if not battle then return nil end
|
if not battle then return nil end
|
||||||
local out = {}
|
local out = {}
|
||||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
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.enemy = okE and enemy or nil
|
||||||
out.player = okP and player or nil
|
out.player = okP and player or nil
|
||||||
if not (out.enemy or out.player) then return nil end
|
if not (out.enemy or out.player) then return nil end
|
||||||
|
out.flash = OverworldBattle.flashing(battle)
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -526,11 +995,17 @@ function OverworldBattle.install()
|
|||||||
-- behind it. There is a world back there now, so they are filled here
|
-- behind it. There is a world back there now, so they are filled here
|
||||||
-- instead -- see BattlePics, which puts the paper back without touching
|
-- instead -- see BattlePics, which puts the paper back without touching
|
||||||
-- the silhouette.
|
-- 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
|
local innerPic = BattleState.picImage
|
||||||
function BattleState:picImage(img)
|
function BattleState:picImage(img)
|
||||||
local out = innerPic(self, img)
|
local out = innerPic(self, img)
|
||||||
if not OverworldBattle.shot() then return out end
|
if not OverworldBattle.shot() then return out end
|
||||||
return BattlePics.filled(out)
|
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- While a billboard texture is being rendered both pics are put in the same
|
-- While a billboard texture is being rendered both pics are put in the same
|
||||||
@@ -590,10 +1065,45 @@ function OverworldBattle.install()
|
|||||||
-- before this screen is composited at all, so the flat pics layer has
|
-- 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
|
-- 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.
|
-- 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
|
innerPics = BattleState.drawPicsLayer
|
||||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||||
if self.dramaticShapeShot then return end
|
local shot = self.dramaticShapeShot
|
||||||
return innerPics(self, slide, sx, sy)
|
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
|
||||||
|
if isIOS() 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
|
end
|
||||||
|
|
||||||
-- Move animations are authored against the pics' fixed slots, and a single
|
-- Move animations are authored against the pics' fixed slots, and a single
|
||||||
@@ -601,7 +1111,7 @@ function OverworldBattle.install()
|
|||||||
-- give them. They ride the average, which is where the pair's centre went
|
-- give them. They ride the average, which is where the pair's centre went
|
||||||
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
|
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
|
||||||
-- aimed at instead of drifting off it.
|
-- aimed at instead of drifting off it.
|
||||||
local innerAnim = BattleState.drawAnimLayer
|
innerAnim = BattleState.drawAnimLayer
|
||||||
function BattleState:drawAnimLayer(colorized)
|
function BattleState:drawAnimLayer(colorized)
|
||||||
local shot = self.dramaticShapeShot
|
local shot = self.dramaticShapeShot
|
||||||
if not shot then return innerAnim(self, colorized) end
|
if not shot then return innerAnim(self, colorized) end
|
||||||
@@ -611,10 +1121,13 @@ function OverworldBattle.install()
|
|||||||
-- mons' projected positions, less the midpoint of the slots they used to
|
-- 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.
|
-- sit in. A hit still lands on the mon it is aimed at.
|
||||||
local a = OverworldBattle.ANCHOR
|
local a = OverworldBattle.ANCHOR
|
||||||
local dx = (shot.enemy[1] + shot.player[1]) / 2
|
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
||||||
- (a.enemy[1] + a.player[1]) / 2
|
-- contributes no movement at all and the pair's centre has gone half as
|
||||||
local dy = (shot.enemy[2] + shot.player[2]) / 2
|
-- far as the foe's mark did.
|
||||||
- (a.enemy[2] + a.player[2]) / 2
|
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.push()
|
||||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||||
local ok, err = pcall(innerAnim, self, colorized)
|
local ok, err = pcall(innerAnim, self, colorized)
|
||||||
@@ -622,6 +1135,47 @@ function OverworldBattle.install()
|
|||||||
if not ok then error(err, 0) end
|
if not ok then error(err, 0) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The engine's flash has a SECOND half, and it is the one that reaches the
|
||||||
|
-- menu. Beside the white rectangle (dropped above) the flash moves are
|
||||||
|
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
|
||||||
|
-- colorized pipeline applies in drawZonePass to the WHOLE background
|
||||||
|
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
|
||||||
|
-- meant for the two mons washed the menu out with them.
|
||||||
|
--
|
||||||
|
-- The fade is left switched on for the pics, which read it through
|
||||||
|
-- picImage, and switched off for the zone pass alone. So the mons flash
|
||||||
|
-- and the furniture around them does not.
|
||||||
|
--
|
||||||
|
-- The zone pass has a SECOND thing it paints, and this is the one that
|
||||||
|
-- reads as the menu box flashing. A screen shake makes it fill every zone
|
||||||
|
-- with the zone's own color 0 before it draws the offset copy -- the
|
||||||
|
-- hardware showing empty BG in the strip the shake vacated. On a white
|
||||||
|
-- battle field that fill is invisible; over a world it is an opaque white
|
||||||
|
-- sheet across the whole frame, and since a shake program alternates
|
||||||
|
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
|
||||||
|
-- switches on and off a few times a second. It is dropped: the background
|
||||||
|
-- here is the map, so what the shake vacates should show the map.
|
||||||
|
local innerZone = BattleState.drawZonePass
|
||||||
|
function BattleState:drawZonePass(src, sx, sy)
|
||||||
|
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
|
||||||
|
-- shadow the method on the instance for this call only; putting the
|
||||||
|
-- field back to whatever it was (normally nil) lets the class method be
|
||||||
|
-- found again
|
||||||
|
local had = rawget(self, "activeBgp")
|
||||||
|
self.activeBgp = function() return nil end
|
||||||
|
local g = love.graphics
|
||||||
|
local rectangle = g.rectangle
|
||||||
|
g.rectangle = function(mode, ...)
|
||||||
|
-- the pass draws no other rectangle; the shake still shifts the copy
|
||||||
|
if mode == "fill" then return end
|
||||||
|
return rectangle(mode, ...)
|
||||||
|
end
|
||||||
|
local ok, err = pcall(innerZone, self, src, sx, sy)
|
||||||
|
g.rectangle = rectangle
|
||||||
|
self.activeBgp = had
|
||||||
|
if not ok then error(err, 0) end
|
||||||
|
end
|
||||||
|
|
||||||
-- Black glyphs on grass are not readable; over a frosted panel measured
|
-- Black glyphs on grass are not readable; over a frosted panel measured
|
||||||
-- dark they are not readable either, so they go white. Mapped rather than
|
-- dark they are not readable either, so they go white. Mapped rather than
|
||||||
-- rewritten: the HUD sets pure black for its text and nothing else, and in
|
-- rewritten: the HUD sets pure black for its text and nothing else, and in
|
||||||
@@ -631,8 +1185,12 @@ function OverworldBattle.install()
|
|||||||
--
|
--
|
||||||
-- The HP bar is untouched: it is drawn in its own greens and reds, and
|
-- The HP bar is untouched: it is drawn in its own greens and reds, and
|
||||||
-- only an exactly-black set is remapped.
|
-- only an exactly-black set is remapped.
|
||||||
local innerHUDs = BattleState.drawHUDs
|
innerHUDs = BattleState.drawHUDs
|
||||||
function BattleState:drawHUDs(slide)
|
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
|
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
|
||||||
return innerHUDs(self, slide)
|
return innerHUDs(self, slide)
|
||||||
end
|
end
|
||||||
@@ -662,19 +1220,133 @@ function OverworldBattle.hudLive(battle, slide)
|
|||||||
return enemy and true or false, player and true or false
|
return enemy and true or false, player and true or false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Lay the frosted glass down under whichever HUD is about to draw, and
|
-- ------- the snapped composite
|
||||||
-- record which way the glyphs have to flip.
|
--
|
||||||
|
-- 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
|
||||||
|
-- With a headset live the HUDs stay IN the GB frame -- the classic
|
||||||
|
-- slots, on the glass drawHudPanels lays for the unsnapped path. Both
|
||||||
|
-- of VR's battle screens (the floating panel and the pokedex's) crop
|
||||||
|
-- to the letterbox, and a block snapped out to the window's edge would
|
||||||
|
-- be cropped away with the window around it.
|
||||||
|
local okV, vr = pcall(V.require, "VR")
|
||||||
|
if okV and vr and vr.active and vr.active() 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)
|
function OverworldBattle.drawHudPanels(battle)
|
||||||
local shot = battle.dramaticShapeShot
|
local shot = battle.dramaticShapeShot
|
||||||
battle.dramaticShapeDark = nil
|
battle.dramaticShapeDark = nil
|
||||||
if not shot then return end
|
if not shot then return end
|
||||||
|
if isIOS() then
|
||||||
|
local slide = (battle.introSlide or 0) * 4
|
||||||
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||||
|
local rect = OverworldBattle.HUD_RECT
|
||||||
|
love.graphics.setColor(1, 1, 1, 0.84)
|
||||||
|
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
|
||||||
|
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
battle.dramaticShapeDark = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if snapped() then
|
||||||
|
battle.dramaticShapeDark = session and session.dark or nil
|
||||||
|
return
|
||||||
|
end
|
||||||
local slide = (battle.introSlide or 0) * 4
|
local slide = (battle.introSlide or 0) * 4
|
||||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||||
if not (enemy or player) then return end
|
|
||||||
local rect = OverworldBattle.HUD_RECT
|
local rect = OverworldBattle.HUD_RECT
|
||||||
local live = {}
|
local live = {}
|
||||||
if enemy then live.enemy = rect.enemy end
|
if enemy then live.enemy = rect.enemy end
|
||||||
if player then live.player = rect.player 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)
|
local dark = BattleHud.verdict(live, shot)
|
||||||
battle.dramaticShapeDark = dark
|
battle.dramaticShapeDark = dark
|
||||||
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
local V = ...
|
||||||
|
|
||||||
|
local PixelCanvas = {}
|
||||||
|
|
||||||
|
function PixelCanvas.new(w, h)
|
||||||
|
return pcall(love.graphics.newCanvas, w, h, { dpiscale = 1 })
|
||||||
|
end
|
||||||
|
|
||||||
|
return PixelCanvas
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
-- VR: the POKEDEX in the player's left hand -- a voxel model of the
|
||||||
|
-- series' own field guide, strapped to the tracked grip pose, whose
|
||||||
|
-- screen is a real texture the mod can put a picture on.
|
||||||
|
--
|
||||||
|
-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE
|
||||||
|
-- -- the text, the menus, the HP bars are the game -- but a flat panel
|
||||||
|
-- floating square in front of the fight hides the fight. A trainer in
|
||||||
|
-- the world already has the right prop for "a handheld device with a
|
||||||
|
-- screen": look down at the Pokedex in your hand to read the battle,
|
||||||
|
-- look up to watch it happen on the map.
|
||||||
|
--
|
||||||
|
-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel),
|
||||||
|
-- around its own centre, front face +Z -- a red slab with the lens, the
|
||||||
|
-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud
|
||||||
|
-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes
|
||||||
|
-- use, so it sits exactly where the hand is and keeps its real size in
|
||||||
|
-- every mode: a hand-sized device over the diorama, the same hand-sized
|
||||||
|
-- device at life scale in first person and in battle.
|
||||||
|
--
|
||||||
|
-- THE SCREEN is a separate one-quad mesh drawn with its own texture --
|
||||||
|
-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands
|
||||||
|
-- it the front buffer during a battle, cropped by UV to the battle's own
|
||||||
|
-- letterbox). No texture leaves the screen dark: a device that is off.
|
||||||
|
--
|
||||||
|
-- Everything here is passive state plus a draw call; VR.lua decides when
|
||||||
|
-- the frame exists (hand tracked, session live) and VoxelScene's eye
|
||||||
|
-- pass draws it after the world, so it composites with real depth
|
||||||
|
-- against everything else.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Mat4 = V.require("Mat4")
|
||||||
|
local Voxel3D = V.require("Voxel3D")
|
||||||
|
local VRRig = V.require("VRRig")
|
||||||
|
|
||||||
|
local Pokedex = {}
|
||||||
|
|
||||||
|
-- one voxel, in metres: a centimetre-ish grid gives the classic chunky
|
||||||
|
-- read at a device you can read a battle off (the body below comes out
|
||||||
|
-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size,
|
||||||
|
-- because the screen carries every menu and was squint-small in hand)
|
||||||
|
Pokedex.VOX = 0.011 * 1.25
|
||||||
|
|
||||||
|
-- Where the device sits relative to the GRIP pose, in metres, and how it
|
||||||
|
-- is tipped. A full quarter turn forward lays the slab exactly along the
|
||||||
|
-- controller's own body -- verified in the headset -- so holding the
|
||||||
|
-- controller IS holding the device: raise your fist and the screen faces
|
||||||
|
-- you. These two are the whole of the attachment.
|
||||||
|
Pokedex.OFFSET = { 0, 0.04, -0.02 }
|
||||||
|
Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward,
|
||||||
|
-- flush with the controller
|
||||||
|
|
||||||
|
-- body proportions, in voxels
|
||||||
|
local W, H, D = 9, 14, 2
|
||||||
|
|
||||||
|
-- the palette the body's faces point their UVs at, one texel per colour
|
||||||
|
local COLORS = {
|
||||||
|
{ 200, 40, 48 }, -- 1 body red
|
||||||
|
{ 140, 24, 32 }, -- 2 hinge / shaded red
|
||||||
|
{ 64, 132, 244 }, -- 3 the lens blue
|
||||||
|
{ 208, 228, 255 }, -- 4 lens glint
|
||||||
|
{ 232, 60, 48 }, -- 5 LED red
|
||||||
|
{ 248, 216, 64 }, -- 6 LED yellow
|
||||||
|
{ 72, 200, 96 }, -- 7 LED green
|
||||||
|
{ 46, 46, 54 }, -- 8 bezel / d-pad dark
|
||||||
|
{ 24, 24, 30 }, -- 9 the dark screen (the "off" state's face)
|
||||||
|
}
|
||||||
|
|
||||||
|
local paletteTex = nil -- one texel per COLORS entry
|
||||||
|
local bodyMesh = nil
|
||||||
|
local screenMesh = nil
|
||||||
|
local screenKey = nil -- the UV rect screenMesh was built for
|
||||||
|
|
||||||
|
local function palette()
|
||||||
|
if paletteTex then return paletteTex end
|
||||||
|
if not (love.image and love.image.newImageData
|
||||||
|
and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
|
||||||
|
if not (ok and data) then return nil end
|
||||||
|
for i, c in ipairs(COLORS) do
|
||||||
|
pcall(data.setPixel, data, i - 1, 0,
|
||||||
|
c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||||
|
end
|
||||||
|
local built, img = pcall(love.graphics.newImage, data)
|
||||||
|
if not built then return nil end
|
||||||
|
pcall(img.setFilter, img, "nearest", "nearest")
|
||||||
|
paletteTex = img
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Append one solid box's six faces to `verts`/`indices`: position in
|
||||||
|
-- voxels (relative to the device centre), size in voxels, colour by
|
||||||
|
-- palette index. Faces carry the mod's own directional shade, so the
|
||||||
|
-- slab reads as a solid the way every extruded block here does.
|
||||||
|
local function box(verts, indices, x, y, z, w, h, d, color)
|
||||||
|
local u = (color - 0.5) / #COLORS
|
||||||
|
local vox = Pokedex.VOX
|
||||||
|
local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox
|
||||||
|
local sx, sy, sz = w * vox, h * vox, d * vox
|
||||||
|
for face = 1, 6 do
|
||||||
|
local corners = Voxel3D.FACE_CORNERS[face]
|
||||||
|
local shade = Voxel3D.FACE_SHADE[face]
|
||||||
|
local n = #verts / 4
|
||||||
|
for _, c in ipairs(corners) do
|
||||||
|
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
|
||||||
|
u, 0.5, shade }
|
||||||
|
end
|
||||||
|
Voxel3D.pushQuad(indices, n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the screen's face on the front, in voxels (10:9, the GB frame's shape),
|
||||||
|
-- shared by the dark "off" face in the body and the live quad
|
||||||
|
local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 }
|
||||||
|
|
||||||
|
local function buildBody()
|
||||||
|
if bodyMesh then return bodyMesh end
|
||||||
|
local verts, indices = {}, {}
|
||||||
|
-- the slab, the hinge along the right edge, the lens, the LEDs, the
|
||||||
|
-- d-pad and two chunky buttons -- the classic cover furniture, one box
|
||||||
|
-- each on the front face (z = D..)
|
||||||
|
box(verts, indices, 0, 0, 0, W, H, D, 1) -- body
|
||||||
|
box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge
|
||||||
|
box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens
|
||||||
|
box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint
|
||||||
|
box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs
|
||||||
|
box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6)
|
||||||
|
box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7)
|
||||||
|
-- the bezel plate the screen sits in, and the dark screen face itself
|
||||||
|
-- (what shows when nothing is on: a device that is off, not a hole)
|
||||||
|
box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D,
|
||||||
|
SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8)
|
||||||
|
box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4,
|
||||||
|
SCREEN.w, SCREEN.h, 0.1, 9)
|
||||||
|
-- d-pad below the screen, two crossed bars, and the A/B buttons
|
||||||
|
box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8)
|
||||||
|
box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8)
|
||||||
|
box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5)
|
||||||
|
box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8)
|
||||||
|
bodyMesh = Voxel3D.newMesh(verts, indices)
|
||||||
|
return bodyMesh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The live screen: one quad a hair proud of the dark face, UV-mapped to
|
||||||
|
-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only
|
||||||
|
-- when the UV rect moves (a window resize moving the battle letterbox).
|
||||||
|
local function buildScreen(uv)
|
||||||
|
local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":")
|
||||||
|
if screenMesh and screenKey == key then return screenMesh end
|
||||||
|
local vox = Pokedex.VOX
|
||||||
|
local x0 = (SCREEN.x - W / 2) * vox
|
||||||
|
local y0 = (SCREEN.y - H / 2) * vox
|
||||||
|
local x1 = x0 + SCREEN.w * vox
|
||||||
|
local y1 = y0 + SCREEN.h * vox
|
||||||
|
local z = (D / 2 + 0.55) * vox
|
||||||
|
local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4]
|
||||||
|
local verts = {
|
||||||
|
{ x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 },
|
||||||
|
{ x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 },
|
||||||
|
}
|
||||||
|
local indices = {}
|
||||||
|
Voxel3D.pushQuad(indices, 0)
|
||||||
|
local mesh = Voxel3D.newMesh(verts, indices)
|
||||||
|
if mesh then
|
||||||
|
screenMesh, screenKey = mesh, key
|
||||||
|
end
|
||||||
|
return mesh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the frame's state, set by VR.lua
|
||||||
|
--
|
||||||
|
-- nil = no pokedex this frame (no session, no tracked left hand).
|
||||||
|
Pokedex.frame = nil
|
||||||
|
|
||||||
|
-- Stand the device on a tracked LEFT-HAND pose under the current
|
||||||
|
-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got).
|
||||||
|
function Pokedex.place(pose, pivot, anchor, scale, yaw)
|
||||||
|
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||||
|
m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2],
|
||||||
|
Pokedex.OFFSET[3]))
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT))
|
||||||
|
Pokedex.frame = { model = m }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What the screen shows: a texture and the UV rect of it to fill the
|
||||||
|
-- screen with. nil for a dark screen. Only meaningful after place().
|
||||||
|
function Pokedex.screen(tex, u0, v0, u1, v1)
|
||||||
|
if Pokedex.frame and tex then
|
||||||
|
Pokedex.frame.tex = tex
|
||||||
|
Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pokedex.clear()
|
||||||
|
Pokedex.frame = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw the device with the scene's own pass (model matrix in world px).
|
||||||
|
-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half --
|
||||||
|
-- a UI prop should receive the world's light, not throw shade on it.
|
||||||
|
function Pokedex.draw()
|
||||||
|
local f = Pokedex.frame
|
||||||
|
if not f then return end
|
||||||
|
local body = buildBody()
|
||||||
|
local pal = palette()
|
||||||
|
if body and pal then
|
||||||
|
Voxel3D.draw(body, pal, f.model)
|
||||||
|
end
|
||||||
|
if f.tex and f.uv then
|
||||||
|
local screen = buildScreen(f.uv)
|
||||||
|
if screen then
|
||||||
|
Voxel3D.draw(screen, f.tex, f.model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- window resize / hot reload: every GPU object here is derived and cheap
|
||||||
|
function Pokedex.invalidate()
|
||||||
|
paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return Pokedex
|
||||||
+122
-5
@@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160
|
|||||||
-- surface shadows itself in a moire of acne; too much and a shadow detaches
|
-- 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
|
-- 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.
|
-- 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 = [[
|
local SHADER = [[
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
@@ -93,17 +130,23 @@ local SHADER = [[
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef PIXEL
|
#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) {
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
// the same alpha discard the main pass uses: a sprite card casts its
|
// the same alpha discard the main pass uses: a sprite card casts its
|
||||||
// silhouette, not its 16x16 bounding box
|
// silhouette, not its 16x16 bounding box
|
||||||
if (Texel(tex, tc).a < 0.5) discard;
|
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;
|
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
|
#endif
|
||||||
]]
|
]]
|
||||||
|
|
||||||
|
ShadowMap._source = function() return SHADER end -- named for the suite
|
||||||
|
|
||||||
local shader = nil -- nil = untried, false = unavailable
|
local shader = nil -- nil = untried, false = unavailable
|
||||||
local canvas = nil -- nil = untried, false = unavailable
|
local canvas = nil -- nil = untried, false = unavailable
|
||||||
local canvasRes = 0 -- the edge `canvas` was made at
|
local canvasRes = 0 -- the edge `canvas` was made at
|
||||||
@@ -143,7 +186,7 @@ end
|
|||||||
local function getCanvas(res)
|
local function getCanvas(res)
|
||||||
if canvas == false then return nil end
|
if canvas == false then return nil end
|
||||||
if canvas and canvasRes == res then return canvas end
|
if canvas and canvasRes == res then return canvas end
|
||||||
local ok, c = pcall(love.graphics.newCanvas, res, res)
|
local ok, c = V.require("PixelCanvas").new(res, res)
|
||||||
if not (ok and c) then
|
if not (ok and c) then
|
||||||
canvas = false
|
canvas = false
|
||||||
return nil
|
return nil
|
||||||
@@ -178,6 +221,9 @@ end
|
|||||||
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
|
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
|
||||||
-- shadows, which need nothing but a quad.
|
-- shadows, which need nothing but a quad.
|
||||||
function ShadowMap.available()
|
function ShadowMap.available()
|
||||||
|
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if not (love.graphics and love.graphics.newCanvas
|
if not (love.graphics and love.graphics.newCanvas
|
||||||
and love.graphics.setDepthMode) then
|
and love.graphics.setDepthMode) then
|
||||||
return false
|
return false
|
||||||
@@ -316,9 +362,58 @@ local function fit(cx, cy, vw, vh)
|
|||||||
-- what the frustum ended up covering, for probes: the lateral extent in
|
-- 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
|
-- world pixels divided by RES is how fine a shadow edge can land
|
||||||
ShadowMap.extent = { r - l, t - b, far - near }
|
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
|
-- the stored depth spans the frustum, so a world-pixel bias is that
|
||||||
-- fraction of it
|
-- 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
|
end
|
||||||
|
|
||||||
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
|
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
|
||||||
@@ -354,6 +449,9 @@ function ShadowMap.begin(cx, cy, vw, vh)
|
|||||||
love.graphics.setShader(sh)
|
love.graphics.setShader(sh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
|
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
|
drawing = true
|
||||||
ready = false
|
ready = false
|
||||||
return true
|
return true
|
||||||
@@ -362,6 +460,25 @@ end
|
|||||||
-- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
|
-- 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
|
-- pull, which is a trick for the VIEW's depth buffer and would drag a
|
||||||
-- shadow off whatever throws it.
|
-- 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)
|
function ShadowMap.draw(mesh, texture, model)
|
||||||
if not (drawing and mesh) then return end
|
if not (drawing and mesh) then return end
|
||||||
local sh = getShader()
|
local sh = getShader()
|
||||||
|
|||||||
+747
@@ -0,0 +1,747 @@
|
|||||||
|
-- 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
|
||||||
|
|
||||||
|
-- How much ELEVATION the gradient spans above the horizon, in radians, for
|
||||||
|
-- a caller that anchors the sky IN SPACE rather than to the frame (the VR
|
||||||
|
-- eyes -- see Voxel3D.beginScene). On the flat screen the bands run from
|
||||||
|
-- the top edge of the frame down to the horizon, which is right for a
|
||||||
|
-- camera whose pitch is the rung's: the frame IS the window on the sky.
|
||||||
|
-- A headset's frame is wherever the head points, so glueing the zenith
|
||||||
|
-- band to its top edge drags the whole gradient around with the head. An
|
||||||
|
-- anchored caller instead hangs the gradient over a fixed slice of sky --
|
||||||
|
-- horizon to ELEV_SPAN up -- and hands paint() the canvas row that span's
|
||||||
|
-- top lands on this frame (the `top` argument), so tilting the head slides
|
||||||
|
-- the frame across a sky that stays put.
|
||||||
|
Sky.ELEV_SPAN = math.rad(55)
|
||||||
|
|
||||||
|
-- ------- 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 top; // where the deepest band begins, in canvas pixels --
|
||||||
|
// 0 glues the gradient to the frame (the flat
|
||||||
|
// screen); an anchored caller passes the row its
|
||||||
|
// fixed elevation span starts on, often negative
|
||||||
|
uniform float cell; // the diorama's pixel size, in canvas pixels
|
||||||
|
uniform float start; // where the checker begins inside a band
|
||||||
|
uniform float axisX; // the "toward the ground" direction on the canvas:
|
||||||
|
uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips
|
||||||
|
// it, and edge/top are distances along it
|
||||||
|
uniform vec3 rayBase; // the eye's ray fan (VRRig eyeCamera.skyRay): a
|
||||||
|
uniform vec3 rayDu; // canvas point at fractions (u, v) looks along
|
||||||
|
uniform vec3 rayDv; // base + u*du + v*dv, world axes -- so each pixel
|
||||||
|
// knows its TRUE elevation and the gradient is a
|
||||||
|
// real skybox, untouched by any head motion
|
||||||
|
uniform float raySpan; // radians of elevation the gradient covers
|
||||||
|
uniform vec2 invSize; // 1/w, 1/h: canvas pixels to fractions
|
||||||
|
uniform float useRay; // 0 = the flat screen's frame-linear gradient
|
||||||
|
uniform float cellAng; // one checker cell in RADIANS (ray path): the
|
||||||
|
// dither's own grid, laid on azimuth/elevation so
|
||||||
|
// the pattern is glued to the SKY -- a screen-cell
|
||||||
|
// parity flips under every head motion and the
|
||||||
|
// whole gradient shimmers
|
||||||
|
uniform float alpha;
|
||||||
|
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
|
||||||
|
uniform vec2 glowPos; // the sun disc, in canvas pixels (flat path)
|
||||||
|
uniform float glowInvR; // 1 / the glow's reach in pixels (flat path)
|
||||||
|
uniform vec3 glowDir; // the sun's world direction (ray path)
|
||||||
|
uniform float glowInvA; // 1 / the glow's reach in radians (ray path)
|
||||||
|
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 tn;
|
||||||
|
float parity;
|
||||||
|
float glowD = 2.0; // past the reach
|
||||||
|
if (useRay > 0.5) {
|
||||||
|
// A SKYBOX, computed instead of stored: the pixel's own ray lands in
|
||||||
|
// a cell of the sky's angular grid (azimuth columns and elevation
|
||||||
|
// rows, cellAng square), and EVERYTHING -- the band, the checker's
|
||||||
|
// parity, the glow -- is answered from that cell's centre. The
|
||||||
|
// screen grid quantises nothing here; that is the point. A screen
|
||||||
|
// quantisation of similar pitch laid under the sky grid beats
|
||||||
|
// against it (moire), and every subpixel head motion re-snaps the
|
||||||
|
// beat -- the fizz. Sampled per pixel, the picture is exactly a
|
||||||
|
// nearest-filtered texture on a dome: its cells slide smoothly with
|
||||||
|
// the world and no motion of the head recomputes the pattern. The
|
||||||
|
// one seam, where azimuth wraps behind the camera, is a single cell
|
||||||
|
// column of a dither pattern.
|
||||||
|
vec3 dir = rayBase + rayDu * (sc.x * invSize.x)
|
||||||
|
+ rayDv * (sc.y * invSize.y);
|
||||||
|
float elev = atan(dir.y, length(dir.xz));
|
||||||
|
float ei = floor(elev / cellAng); // elevation row
|
||||||
|
if (ei < 0.0) { discard; } // below the horizon
|
||||||
|
float ai = floor(atan(dir.x, dir.z) / cellAng); // azimuth column
|
||||||
|
float elc = (ei + 0.5) * cellAng; // the row's centre
|
||||||
|
tn = 1.0 - clamp(elc / max(raySpan, 0.001), 0.0, 1.0);
|
||||||
|
parity = mod(ai + ei, 2.0);
|
||||||
|
if (glowAmt > 0.0) {
|
||||||
|
// the glow by the angle between the CELL's centre direction and
|
||||||
|
// the sun's own, so its rings are pinned to the same sky grid
|
||||||
|
float azc = (ai + 0.5) * cellAng;
|
||||||
|
vec3 cd = vec3(cos(elc) * sin(azc), sin(elc), cos(elc) * cos(azc));
|
||||||
|
glowD = acos(clamp(dot(cd, glowDir), -1.0, 1.0)) * glowInvA;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec2 cc0 = floor(sc / cell) * cell; // top of this cell
|
||||||
|
float row = cc0.x * axisX + cc0.y * axisY; // along the axis
|
||||||
|
if (row > edge) { discard; } // below the horizon
|
||||||
|
tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0);
|
||||||
|
parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||||
|
if (glowAmt > 0.0) {
|
||||||
|
vec2 cc = (floor(sc / cell) + 0.5) * cell;
|
||||||
|
glowD = length(cc - glowPos) * glowInvR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float pos = tn * count;
|
||||||
|
float base = min(floor(pos), count - 1.0);
|
||||||
|
vec3 c = bandAt(base);
|
||||||
|
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 --
|
||||||
|
// measured cell-to-cell on the flat frame and angle-to-angle on the
|
||||||
|
// skybox, so its rings ride whichever grid the checker itself is on.
|
||||||
|
if (glowAmt > 0.0) {
|
||||||
|
float g = glowAmt * pow(clamp(1.0 - glowD, 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, top)
|
||||||
|
local g = love.graphics
|
||||||
|
local n = #bands
|
||||||
|
local span = edge - (top or 0)
|
||||||
|
local prev = 0
|
||||||
|
for i = 1, n do
|
||||||
|
local cut = (i == n) and math.min(h, math.ceil(edge))
|
||||||
|
or math.floor(((top or 0) + i / n * span) / 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
|
||||||
|
|
||||||
|
-- One disc's worth of cell art -- shared verbatim by the screen-space
|
||||||
|
-- painter below (the flat screen) and by the BAKE the VR eyes texture
|
||||||
|
-- their world-anchored quad with (Sky.discImage). `plot(dx, dy, c)` gets
|
||||||
|
-- every kept cell in disc-local cell coordinates and its 0..255 colour.
|
||||||
|
local function discCells(r, moon, shades, twilight, plot)
|
||||||
|
local core = shades[1]
|
||||||
|
local main = shades[twilight and 3 or 2]
|
||||||
|
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 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 plot(dx, dy, c) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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 sx, sy, sw, sh = g.getScissor()
|
||||||
|
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||||
|
discCells(r, body.moon, shades, twilight, function(dx, dy, c)
|
||||||
|
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)
|
||||||
|
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
|
||||||
|
g.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the disc as a TEXTURE, for the VR eyes
|
||||||
|
--
|
||||||
|
-- A VR eye must not paint the disc in screen space at all: a canvas-grid
|
||||||
|
-- painting re-snaps to different cells every head movement (jitter) and
|
||||||
|
-- holds its pattern square to the CANVAS (a rolled or pitched head
|
||||||
|
-- watches the sun's face turn). So the same cell art is baked once into
|
||||||
|
-- a texture, and Voxel3D hangs it on a quad ANCHORED IN THE WORLD --
|
||||||
|
-- projected through the eye's own matrix like any geometry, stable under
|
||||||
|
-- every head motion. Rebaked only when the palette or the twilight state
|
||||||
|
-- moves the colours.
|
||||||
|
local discBake = { key = nil, img = nil }
|
||||||
|
|
||||||
|
Sky.DISC_BAKE_R = 9 -- bake radius, in cells
|
||||||
|
Sky.DISC_BAKE_PX = 8 -- texture pixels per cell
|
||||||
|
|
||||||
|
function Sky.discImage(moon, twilight)
|
||||||
|
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||||
|
local shades = Sky.discShades(moon)
|
||||||
|
local key = (moon and "m" or "s") .. (twilight and "t" or "-")
|
||||||
|
for i = 1, math.min(3, #shades) do
|
||||||
|
local c = shades[i]
|
||||||
|
key = key .. ":" .. c[1] .. "," .. c[2] .. "," .. c[3]
|
||||||
|
end
|
||||||
|
if discBake.key == key and discBake.img then return discBake.img end
|
||||||
|
local r, px = Sky.DISC_BAKE_R, Sky.DISC_BAKE_PX
|
||||||
|
local size = (2 * r + 1) * px
|
||||||
|
local ok, canvas = pcall(love.graphics.newCanvas, size, size)
|
||||||
|
if not (ok and canvas) then return nil end
|
||||||
|
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||||
|
local g = love.graphics
|
||||||
|
local done = pcall(function()
|
||||||
|
g.push("all")
|
||||||
|
g.origin()
|
||||||
|
g.setCanvas(canvas)
|
||||||
|
g.clear(0, 0, 0, 0)
|
||||||
|
g.setBlendMode("alpha")
|
||||||
|
discCells(r, moon, shades, twilight, function(dx, dy, c)
|
||||||
|
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||||
|
g.rectangle("fill", (dx + r) * px, (dy + r) * px, px, px)
|
||||||
|
end)
|
||||||
|
g.pop()
|
||||||
|
end)
|
||||||
|
if not done then return nil end
|
||||||
|
discBake.key, discBake.img = key, canvas
|
||||||
|
return canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether this body is the looming low sun, for callers sizing the baked
|
||||||
|
-- disc (the same exaggeration paintDisc applies through discRadius).
|
||||||
|
function Sky.discLooming(glowAmt, moon)
|
||||||
|
return (glowAmt or 0) > 0.25 and not moon
|
||||||
|
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.
|
||||||
|
--
|
||||||
|
-- `top` anchors the gradient in space rather than to the frame: the canvas
|
||||||
|
-- row band 1 starts on (often negative -- above the frame), from a caller
|
||||||
|
-- that mapped a fixed elevation span to its own camera (see ELEV_SPAN).
|
||||||
|
-- nil or 0 is the flat screen's behaviour: zenith band at the top edge.
|
||||||
|
--
|
||||||
|
-- `axis` tips the whole painting to a rolled camera's true horizon: a unit
|
||||||
|
-- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine),
|
||||||
|
-- with `horizonY` and `top` then read as distances ALONG it rather than as
|
||||||
|
-- rows. nil is the level default. Only the shader path can tilt; the flat
|
||||||
|
-- fallback paints level, which only a headless run ever sees. Under an
|
||||||
|
-- axis the DISC is not painted here at all -- the VR caller hangs the
|
||||||
|
-- baked disc (Sky.discImage) in the world instead; `body` still carries
|
||||||
|
-- the twilight glow into the bands.
|
||||||
|
--
|
||||||
|
-- `ray` makes the gradient a SKYBOX: the eye's own ray fan (the camera
|
||||||
|
-- record's skyRay, from VRRig.eyeCamera), letting every pixel take its
|
||||||
|
-- band from its TRUE elevation -- so no motion of the head, on any axis,
|
||||||
|
-- moves a band; only the clock does. nil keeps the linear frame gradient
|
||||||
|
-- the flat screen has always painted.
|
||||||
|
--
|
||||||
|
-- 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, top, axis, ray)
|
||||||
|
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
|
||||||
|
-- with a ray fan the shader's own per-pixel elevation test is the only
|
||||||
|
-- boundary and the whole frame goes through it; along an axis the
|
||||||
|
-- caller's edge is already the signed distance and has no row to be
|
||||||
|
-- clamped to; level callers keep the SPAN fallback
|
||||||
|
local edge
|
||||||
|
if ray then
|
||||||
|
edge = h
|
||||||
|
elseif axis then
|
||||||
|
edge = horizonY
|
||||||
|
else
|
||||||
|
edge = Sky.region(h, horizonY)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
-- the skybox glow needs the sun's world DIRECTION (skyBody carries it);
|
||||||
|
-- a body without one has nothing to measure angles against, so no glow
|
||||||
|
if ray and glowAmt > 0 and not (body and body.dx) then glowAmt = 0 end
|
||||||
|
-- the world direction a canvas fraction (u, v) looks along, normalised
|
||||||
|
-- -- for sizing the angular checker and the glow's angular reach below
|
||||||
|
local function rayDirAt(u, v)
|
||||||
|
local b, du, dv = ray.base, ray.du, ray.dv
|
||||||
|
local x = b[1] + du[1] * u + dv[1] * v
|
||||||
|
local y = b[2] + du[2] * u + dv[2] * v
|
||||||
|
local z = b[3] + du[3] * u + dv[3] * v
|
||||||
|
local l = math.sqrt(x * x + y * y + z * z)
|
||||||
|
if l < 1e-9 then return 0, 0, -1 end
|
||||||
|
return x / l, y / l, z / l
|
||||||
|
end
|
||||||
|
local function rayAngle(u0, v0, u1, v1)
|
||||||
|
local ax, ay, az = rayDirAt(u0, v0)
|
||||||
|
local bx, by, bz = rayDirAt(u1, v1)
|
||||||
|
local d = ax * bx + ay * by + az * bz
|
||||||
|
return math.acos(math.max(-1, math.min(1, d)))
|
||||||
|
end
|
||||||
|
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("top", math.min(top or 0, edge - 1))
|
||||||
|
sh:send("axisX", axis and axis[1] or 0)
|
||||||
|
sh:send("axisY", axis and axis[2] or 1)
|
||||||
|
sh:send("useRay", ray and 1 or 0)
|
||||||
|
if ray then
|
||||||
|
sh:send("rayBase", ray.base)
|
||||||
|
sh:send("rayDu", ray.du)
|
||||||
|
sh:send("rayDv", ray.dv)
|
||||||
|
sh:send("raySpan", Sky.ELEV_SPAN)
|
||||||
|
sh:send("invSize", { 1 / w, 1 / h })
|
||||||
|
-- the angular checker's cell: the angle one dither cell spans at
|
||||||
|
-- the frame's centre, so the sky-glued grid comes out the same
|
||||||
|
-- size on screen as the diorama's own pixel grid
|
||||||
|
sh:send("cellAng",
|
||||||
|
math.max(1e-4, rayAngle(0.5, 0, 0.5, 1) * cell / h))
|
||||||
|
end
|
||||||
|
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 }
|
||||||
|
if ray then
|
||||||
|
-- the glow in ANGLES: its direction is the sun's own, and its
|
||||||
|
-- reach is the same fraction of the view the pixel reach was
|
||||||
|
-- of the frame, so the two paths agree on how wide it looks
|
||||||
|
local dx, dy, dz = body.dx, body.dy, body.dz
|
||||||
|
local l = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
sh:send("glowDir", { dx / l, dy / l, dz / l })
|
||||||
|
sh:send("glowInvA", 1 / math.max(
|
||||||
|
1e-3, rayAngle(0, 0.5, 1, 0.5) * Sky.GLOW_REACH))
|
||||||
|
else
|
||||||
|
sh:send("glowPos", { body.x, body.y })
|
||||||
|
sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
|
||||||
|
end
|
||||||
|
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)
|
||||||
|
-- tilted or rayed, the sky's reach is not a row: the full frame
|
||||||
|
-- goes through the shader and the discard is the boundary
|
||||||
|
local rectH = (axis or ray) and h or math.min(h, math.ceil(edge))
|
||||||
|
g.rectangle("fill", 0, 0, w, rectH)
|
||||||
|
g.setShader()
|
||||||
|
else
|
||||||
|
sh = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not sh then
|
||||||
|
paintFlat(w, h, bands, (axis or ray) and math.min(h, edge) or edge,
|
||||||
|
alpha, cell, math.min(top or 0, edge - 1))
|
||||||
|
end
|
||||||
|
-- the disc goes over the glow, under nothing: plain rectangles, so it is
|
||||||
|
-- there whether or not the shader built. NOT under an axis or a ray fan:
|
||||||
|
-- those cameras hang the baked disc in the world instead (drawWorldDisc,
|
||||||
|
-- with Sky.discImage)
|
||||||
|
if not (axis or ray) then
|
||||||
|
paintDisc(body, math.min(h, edge), cell, w, h)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
if discBake.img and discBake.img.release then
|
||||||
|
pcall(discBake.img.release, discBake.img)
|
||||||
|
end
|
||||||
|
discBake.key, discBake.img = 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 prev = love.graphics.getCanvas()
|
||||||
local ok, data = pcall(function()
|
local ok, data = pcall(function()
|
||||||
local w, h = image:getDimensions()
|
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.setCanvas(canvas)
|
||||||
love.graphics.clear(0, 0, 0, 0)
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
-- straight copy: no blending against the cleared target, no tint from
|
-- straight copy: no blending against the cleared target, no tint from
|
||||||
|
|||||||
+351
-17
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
|
|||||||
sign = 12,
|
sign = 12,
|
||||||
wall = 16,
|
wall = 16,
|
||||||
tree = 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,
|
roof = 28,
|
||||||
cylinder = 16,
|
cylinder = 16,
|
||||||
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
|
-- 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
|
-- body builds from the bark rows and the drawn ellipse projects onto
|
||||||
-- the hull's round top
|
-- the hull's round top
|
||||||
stump = 16,
|
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,
|
billboard = 16,
|
||||||
signpost = 16,
|
signpost = 16,
|
||||||
post = 16,
|
post = 16,
|
||||||
@@ -77,10 +98,18 @@ local FALLBACK_HEIGHTS = {
|
|||||||
bed = 7,
|
bed = 7,
|
||||||
stool = 8,
|
stool = 8,
|
||||||
counter = 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,
|
table = 12,
|
||||||
desk = 24,
|
desk = 24,
|
||||||
prop = 16,
|
prop = 16,
|
||||||
cutout = 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,
|
console = 16,
|
||||||
relief = 3,
|
relief = 3,
|
||||||
bookcase = 32,
|
bookcase = 32,
|
||||||
@@ -110,12 +139,15 @@ local ART = {
|
|||||||
ledge = "top",
|
ledge = "top",
|
||||||
roof = "top",
|
roof = "top",
|
||||||
wall = "upright",
|
wall = "upright",
|
||||||
|
cliff = "upright",
|
||||||
tree = "upright",
|
tree = "upright",
|
||||||
fence = "upright",
|
fence = "upright",
|
||||||
sign = "upright",
|
sign = "upright",
|
||||||
cylinder = "cylinder",
|
cylinder = "cylinder",
|
||||||
canopy = "canopy",
|
canopy = "canopy",
|
||||||
stump = "cylinder",
|
stump = "cylinder",
|
||||||
|
can = "cylinder",
|
||||||
|
planter = "planter",
|
||||||
billboard = "billboard",
|
billboard = "billboard",
|
||||||
-- signposts share the billboard treatment but as their own pool at a
|
-- 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
|
-- 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
|
-- profile archetype Structures builds real steps for -- rising flights
|
||||||
-- for stairs leading up, sunken stairwells for stairs leading down
|
-- for stairs leading up, sunken stairwells for stairs leading down
|
||||||
bed = "top",
|
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",
|
stool = "billboard",
|
||||||
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
-- 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
|
-- so exactly the drawing's bottom row stands up as the front and
|
||||||
@@ -151,6 +186,13 @@ local ART = {
|
|||||||
desk = "upright",
|
desk = "upright",
|
||||||
prop = "billboard",
|
prop = "billboard",
|
||||||
cutout = "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
|
-- a machine standing on furniture: the billboard treatment with
|
||||||
-- body, plus the one-object contract `cutout` has -- the drawing is
|
-- body, plus the one-object contract `cutout` has -- the drawing is
|
||||||
-- ringed by the furniture it sits on, and those edges must not be
|
-- 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 spec = nil -- the loaded data file, or false when absent
|
||||||
local cache = {} -- tileset id -> resolved shape list
|
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
|
-- 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
|
-- 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
|
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
|
||||||
-- the map and coordinates are in hand. First match wins; no match keeps
|
-- the map and coordinates are in hand. First match wins; no match keeps
|
||||||
-- the tile's ordinary pin.
|
-- 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 function authoredConditions(tilesetId, heights)
|
||||||
local s = load()
|
local s = load()
|
||||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||||
local spec = entry and entry.when_above
|
if type(entry) ~= "table" then return nil end
|
||||||
if type(spec) ~= "table" then return nil end
|
|
||||||
local out, any = {}, false
|
local out, any = {}, false
|
||||||
for tile, rules in pairs(spec) do
|
|
||||||
if type(tile) == "number" and type(rules) == "table" then
|
local function collect(spec, side)
|
||||||
local list = {}
|
if type(spec) ~= "table" then return end
|
||||||
for _, rule in ipairs(rules) do
|
for tile, rules in pairs(spec) do
|
||||||
if type(rule) == "table" and heights[rule.class]
|
if type(tile) == "number" and type(rules) == "table" then
|
||||||
and type(rule.above) == "table" then
|
local list = out[tile] or {}
|
||||||
local set = {}
|
for _, rule in ipairs(rules) do
|
||||||
for _, t in ipairs(rule.above) do set[t] = true end
|
if type(rule) == "table" and heights[rule.class]
|
||||||
list[#list + 1] = { above = set, class = 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
|
||||||
end
|
|
||||||
if #list > 0 then
|
|
||||||
out[tile] = list
|
|
||||||
any = true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
collect(entry.when_above, "above")
|
||||||
|
collect(entry.when_below, "below")
|
||||||
return any and out or nil
|
return any and out or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -273,6 +331,24 @@ function TileShape.forMap(map)
|
|||||||
if cache[id] then return cache[id] end
|
if cache[id] then return cache[id] end
|
||||||
|
|
||||||
local heights = TileShape.heights()
|
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 authored = authoredGroups(id, heights)
|
||||||
local count = math.floor((tileset.imageWidth or 128) / 8)
|
local count = math.floor((tileset.imageWidth or 128) / 8)
|
||||||
* math.floor((tileset.imageHeight or 48) / 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)
|
-- tile and the cell rules below (see authoredConditions)
|
||||||
local rules = shapes.cond and shapes.cond[tile]
|
local rules = shapes.cond and shapes.cond[tile]
|
||||||
if rules then
|
if rules then
|
||||||
local above = map:tileAt(tx, ty - 1)
|
|
||||||
for _, rule in ipairs(rules) do
|
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.condShape, NOT shapes.classes: the canonical class
|
||||||
-- shapes are SHARED, and `wall` in particular is the very object
|
-- shapes are SHARED, and `wall` in particular is the very object
|
||||||
-- rule 4 hands every unauthored solid tile. Marking that one
|
-- rule 4 hands every unauthored solid tile. Marking that one
|
||||||
@@ -369,11 +448,266 @@ function TileShape.at(map, shapes, tile, tx, ty)
|
|||||||
return s
|
return s
|
||||||
end
|
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
|
-- 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).
|
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||||
function TileShape.invalidate()
|
function TileShape.invalidate()
|
||||||
spec = nil
|
spec = nil
|
||||||
cache = {}
|
cache = {}
|
||||||
|
figCache = {}
|
||||||
|
mntCache = {}
|
||||||
|
bgCache = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
return TileShape
|
return TileShape
|
||||||
|
|||||||
+4
-2
@@ -25,6 +25,7 @@
|
|||||||
-- failure -- headless, no shader support) apply() hands the canvas back
|
-- failure -- headless, no shader support) apply() hands the canvas back
|
||||||
-- untouched, so every other path is byte-for-byte what it always was.
|
-- untouched, so every other path is byte-for-byte what it always was.
|
||||||
|
|
||||||
|
local V = ...
|
||||||
local TiltShift = {}
|
local TiltShift = {}
|
||||||
|
|
||||||
TiltShift.level = 0
|
TiltShift.level = 0
|
||||||
@@ -85,9 +86,10 @@ end
|
|||||||
|
|
||||||
local function getCanvases(w, h)
|
local function getCanvases(w, h)
|
||||||
if not ping or cw ~= w or ch ~= h then
|
if not ping or cw ~= w or ch ~= h then
|
||||||
local ok, a = pcall(love.graphics.newCanvas, w, h)
|
local PixelCanvas = V.require("PixelCanvas")
|
||||||
|
local ok, a = PixelCanvas.new(w, h)
|
||||||
if not ok then return nil end
|
if not ok then return nil end
|
||||||
local okB, b = pcall(love.graphics.newCanvas, w, h)
|
local okB, b = PixelCanvas.new(w, h)
|
||||||
if not okB then return nil end
|
if not okB then return nil end
|
||||||
-- the gaussian's fractional tap offsets need linear filtering
|
-- the gaussian's fractional tap offsets need linear filtering
|
||||||
a:setFilter("linear", "linear")
|
a:setFilter("linear", "linear")
|
||||||
|
|||||||
+781
@@ -0,0 +1,781 @@
|
|||||||
|
-- VR: the conductor -- one call per game frame that runs the whole
|
||||||
|
-- headset side, and the row that switches it on.
|
||||||
|
--
|
||||||
|
-- The shape of a VR frame, from the pipeline's update hook (which ticks
|
||||||
|
-- every frame whatever is on the stack, which is exactly what a headset
|
||||||
|
-- needs -- the world must keep arriving through menus, dialogs and
|
||||||
|
-- battles):
|
||||||
|
--
|
||||||
|
-- poll the runtime's events (begin the session when it says READY)
|
||||||
|
-- xrWaitFrame <- BLOCKS until the headset wants a frame;
|
||||||
|
-- with vsync handed off (set to 0 while the
|
||||||
|
-- session runs) this is what paces the whole
|
||||||
|
-- app at headset rate, while FixedStep keeps
|
||||||
|
-- the game's own logic at its 60 Hz
|
||||||
|
-- locate the two eyes
|
||||||
|
-- render the world once per eye (VoxelScene.render's `eyes` path:
|
||||||
|
-- shared shadow map, shared pose capture, per-eye cameras from VRRig)
|
||||||
|
-- blit each eye canvas into its swapchain image (VRGL)
|
||||||
|
-- copy the window's front buffer into the UI quad when a menu, dialog,
|
||||||
|
-- battle or wipe is what the flat screen is showing
|
||||||
|
-- xrEndFrame with the projection layer and/or the quad
|
||||||
|
--
|
||||||
|
-- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit
|
||||||
|
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
|
||||||
|
-- your head started -- lean in, walk around it; on 1ST you stand inside
|
||||||
|
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
|
||||||
|
-- walks where you look exactly as it does on the flat screen. A STAGED
|
||||||
|
-- FIGHT takes the camera from both: the headset snaps -- through a fade
|
||||||
|
-- to black and back -- to the flat battle's own over-the-shoulder seat
|
||||||
|
-- (VRRig.battleMount), and returns the same way when the fight ends;
|
||||||
|
-- the 2D battle screen lights up on the POKEDEX in the tracked left
|
||||||
|
-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all --
|
||||||
|
-- the fight itself owns the view. The flat window keeps
|
||||||
|
-- running as the mirror (left eye when the world is up), so menus stay
|
||||||
|
-- usable at the desk and every existing input keeps working alongside
|
||||||
|
-- the XR controllers.
|
||||||
|
--
|
||||||
|
-- Failure is a status, never a crash: no runtime, no headset, no GL
|
||||||
|
-- interop, or a mid-session loss all land back on the flat screen with
|
||||||
|
-- the reason readable off VR.status().
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local ModSetting = V.require("ModSetting")
|
||||||
|
local Voxel = V.require("VoxelState")
|
||||||
|
local Voxel3D = V.require("Voxel3D")
|
||||||
|
local VoxelScene = V.require("VoxelScene")
|
||||||
|
local FirstPerson = V.require("FirstPerson")
|
||||||
|
local BattleCam = V.require("BattleCam")
|
||||||
|
local VRRig = V.require("VRRig")
|
||||||
|
local VRXR = V.require("VRXR")
|
||||||
|
local VRGL = V.require("VRGL")
|
||||||
|
local Pokedex = V.require("Pokedex")
|
||||||
|
|
||||||
|
local VR = {}
|
||||||
|
|
||||||
|
-- the row: plain OFF/ON. No hotkey -- the engine's display keys are
|
||||||
|
-- spoken for, and a headset is not something to toggle by accident.
|
||||||
|
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
|
||||||
|
|
||||||
|
-- How the right stick turns you in first person. OFF is the 45-degree
|
||||||
|
-- SNAP this mod shipped with and the reason for it is comfort, not
|
||||||
|
-- taste: a software turn moves the world past a head that did not move,
|
||||||
|
-- which is vection with no vestibular signal to match it, and it is the
|
||||||
|
-- single most reliable way to make somebody ill in a headset. A snap
|
||||||
|
-- gives the inner ear nothing to disagree with.
|
||||||
|
--
|
||||||
|
-- But snap turning is not free either -- it costs continuity, and the
|
||||||
|
-- players who have their sea legs generally want the stick. So it is a
|
||||||
|
-- row rather than a decision: OFF by default, on for anyone who asks,
|
||||||
|
-- and the row only exists while there is a headset to use it in.
|
||||||
|
VR.smoothTurn = ModSetting.new("smoothturn", "SMOOTH TURN",
|
||||||
|
{ false, true }, { "OFF", "ON" })
|
||||||
|
|
||||||
|
-- radians per second at full deflection, with a squared response so the
|
||||||
|
-- first half of the throw aims and the rest turns -- the same curve
|
||||||
|
-- FirstPerson gives the flat screen's right stick
|
||||||
|
VR.SMOOTH_TURN_RATE = 2.2
|
||||||
|
|
||||||
|
-- Where the diorama's UI panel floats vs first person's. These are the
|
||||||
|
-- FALLBACK screens: wherever the pokedex is up and lit -- first
|
||||||
|
-- person's menus, a battle's 2D scene -- no quad is submitted at all
|
||||||
|
-- (see updateQuad), and these serve only the diorama and the no-tracked-
|
||||||
|
-- controller case.
|
||||||
|
local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 }
|
||||||
|
local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 }
|
||||||
|
|
||||||
|
local started = false -- start() succeeded this enablement
|
||||||
|
local failed = nil -- start() failed; wait for a re-toggle
|
||||||
|
local wasOn = false
|
||||||
|
local savedVsync = nil
|
||||||
|
local fboCache = setmetatable({}, { __mode = "k" }) -- canvas -> GL FBO id
|
||||||
|
local mirrorSrc = nil -- last left-eye canvas, for the window
|
||||||
|
local mirrorCanvas = nil
|
||||||
|
local status = "off"
|
||||||
|
|
||||||
|
-- the diorama's live adjustments: the right stick's zoom (a multiplier on
|
||||||
|
-- the model's size) and the grab-drag's height (metres of world travel)
|
||||||
|
local zoom = 1
|
||||||
|
local heightOff = 0
|
||||||
|
local held = {} -- GB buttons this module is holding down
|
||||||
|
local lastHandY = nil -- the gripping hand's height, last frame
|
||||||
|
|
||||||
|
-- First person's SNAP TURN: the right stick flicked left or right steps
|
||||||
|
-- the whole XR-to-world mapping 45 degrees at a time (a smooth software
|
||||||
|
-- turn is the classic comfort mistake -- vection with no vestibular
|
||||||
|
-- signal; a snap is instant and the head does the rest). The offset
|
||||||
|
-- turns the mapping itself, so the eyes, the walk direction and the
|
||||||
|
-- pokedex all agree about which way the world now faces.
|
||||||
|
local SNAP_TURN = math.rad(45)
|
||||||
|
local fpYawOff = 0 -- accumulated snaps, radians
|
||||||
|
local snapArmed = true -- re-arms when the stick returns to centre
|
||||||
|
|
||||||
|
local function wrapPi(a)
|
||||||
|
return (a + math.pi) % (2 * math.pi) - math.pi
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The battle snap, made a FADE rather than a cut: when a fight is staged
|
||||||
|
-- on the world (or stops being), black rises over both eyes, the camera
|
||||||
|
-- swaps mounts behind it, and black lifts. A teleport inside VR is the
|
||||||
|
-- one camera move that should never be SEEN happening -- the world
|
||||||
|
-- sliding to a new seat reads as the room moving.
|
||||||
|
local FADE_TIME = 0.35 -- seconds each way: out, then back in
|
||||||
|
local camMode = "explore" -- "explore" (diorama / 1ST) or "battle"
|
||||||
|
local fadeAlpha = 0 -- the black over the eyes right now
|
||||||
|
|
||||||
|
-- The staged fight to look at, if there is one: arena, floor height.
|
||||||
|
local function battleStage()
|
||||||
|
local ok, arena, groundY = pcall(function()
|
||||||
|
return V.require("OverworldBattle").stage()
|
||||||
|
end)
|
||||||
|
if not ok then return nil end
|
||||||
|
return arena, groundY
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the palette closure the engine hands drawWorld; stashed there (see
|
||||||
|
-- main.lua) because the VR frame renders from update, where no ctx exists
|
||||||
|
VR.paletteFor = nil
|
||||||
|
|
||||||
|
-- Whether this platform can do VR AT ALL: the shipped loader and the GL
|
||||||
|
-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies.
|
||||||
|
-- Everywhere else -- Android above all -- the row is not offered on any
|
||||||
|
-- menu, and a stored vr=true is ignored rather than read: a save that
|
||||||
|
-- migrated over from the desktop must not leave a phone trying to start
|
||||||
|
-- an OpenXR session (or silently forcing the battle rows). Headless runs
|
||||||
|
-- have no love.system and answer true, which costs nothing: enabling VR
|
||||||
|
-- there stops at VRXR.start like it always did.
|
||||||
|
function VR.supported()
|
||||||
|
local ok, os = pcall(function() return love.system.getOS() end)
|
||||||
|
if not ok or not os then return true end
|
||||||
|
return os == "Windows"
|
||||||
|
end
|
||||||
|
|
||||||
|
function VR.enabled()
|
||||||
|
return VR.supported() and VR.setting:get() == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function VR.active()
|
||||||
|
return started and VRXR.isRunning()
|
||||||
|
end
|
||||||
|
|
||||||
|
function VR.status()
|
||||||
|
if not VR.enabled() then return "off" end
|
||||||
|
if failed then return failed end
|
||||||
|
return VRXR.status()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Let go of every input this module was holding: the GB buttons pressed
|
||||||
|
-- through the overlay path, and the synthetic left stick. Runs when the
|
||||||
|
-- session ends and whenever a frame has no controller state to read.
|
||||||
|
local function releaseInputs()
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
if not ok or not Game.input then return end
|
||||||
|
for btn in pairs(held) do
|
||||||
|
pcall(function() Game.input:overlayReleased(btn) end)
|
||||||
|
held[btn] = nil
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
Game.input:gamepadaxis(nil, "leftx", 0)
|
||||||
|
Game.input:gamepadaxis(nil, "lefty", 0)
|
||||||
|
end)
|
||||||
|
lastHandY = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shutdown(reason)
|
||||||
|
if started then
|
||||||
|
VRXR.stop()
|
||||||
|
started = false
|
||||||
|
end
|
||||||
|
if savedVsync ~= nil then
|
||||||
|
pcall(love.window.setVSync, savedVsync)
|
||||||
|
savedVsync = nil
|
||||||
|
end
|
||||||
|
-- the placed camera may still be a VR eye's; the orbit must get the
|
||||||
|
-- pass back clean
|
||||||
|
Voxel3D.camera = nil
|
||||||
|
mirrorSrc = nil
|
||||||
|
releaseInputs()
|
||||||
|
BattleCam.still = false
|
||||||
|
VoxelScene.spriteLean = nil
|
||||||
|
Pokedex.clear()
|
||||||
|
-- the horde's gun too: its VR frame is a matrix built from a hand pose,
|
||||||
|
-- and a stale one left behind would pin the model to wherever the
|
||||||
|
-- controller was when the session died -- on the FLAT screen, where the
|
||||||
|
-- view model should have taken over
|
||||||
|
V.require("HordeGun").clear()
|
||||||
|
zoom, heightOff = 1, 0
|
||||||
|
fpYawOff, snapArmed = 0, true
|
||||||
|
camMode, fadeAlpha = "explore", 0
|
||||||
|
status = reason or "off"
|
||||||
|
end
|
||||||
|
|
||||||
|
VR.shutdown = shutdown -- named for the probe driver
|
||||||
|
|
||||||
|
-- Whether the flat screen is showing something the world pass cannot: a
|
||||||
|
-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's
|
||||||
|
-- screen both key on it.
|
||||||
|
local function uiShowing()
|
||||||
|
local ok, showing = pcall(function()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local top = Game.stack and Game.stack:top()
|
||||||
|
return top ~= Game.overworld
|
||||||
|
or (Game.overworld and Game.overworld.transitioning) or false
|
||||||
|
end)
|
||||||
|
return ok and showing or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the pokedex's screen
|
||||||
|
--
|
||||||
|
-- What the device in the hand shows during a battle: the flat window --
|
||||||
|
-- which IS the 2D battle screen for as long as the battle state draws --
|
||||||
|
-- copied into a canvas the scene pass can texture with, cropped by UV to
|
||||||
|
-- the battle's own letterbox so the screen wears the GB frame edge to
|
||||||
|
-- edge. Menus over the battle (the party, the bag) ride along for free:
|
||||||
|
-- they are the flat screen too, and reading them on the device in your
|
||||||
|
-- hand is exactly the point.
|
||||||
|
local dexCanvas = nil
|
||||||
|
|
||||||
|
local function dexScreen()
|
||||||
|
local ok, out = pcall(function()
|
||||||
|
local ww, wh = love.graphics.getPixelDimensions()
|
||||||
|
if not (ww and ww > 0 and wh and wh > 0) then return nil end
|
||||||
|
if not (dexCanvas and dexCanvas:getWidth() == ww
|
||||||
|
and dexCanvas:getHeight() == wh) then
|
||||||
|
dexCanvas = love.graphics.newCanvas(ww, wh)
|
||||||
|
pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest")
|
||||||
|
end
|
||||||
|
local fbo = fboCache[dexCanvas]
|
||||||
|
if not fbo then
|
||||||
|
fbo = VRGL.canvasFBO(dexCanvas)
|
||||||
|
fboCache[dexCanvas] = fbo
|
||||||
|
end
|
||||||
|
if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end
|
||||||
|
local BattleScene = V.require("BattleScene")
|
||||||
|
local lx, ly, s = BattleScene.letterbox()
|
||||||
|
return { dexCanvas,
|
||||||
|
lx / ww, ly / wh,
|
||||||
|
(lx + BattleScene.GB_W * s) / ww,
|
||||||
|
(ly + BattleScene.GB_H * s) / wh }
|
||||||
|
end)
|
||||||
|
return ok and out or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the world, once per eye
|
||||||
|
|
||||||
|
local function renderWorld(views, ctl)
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
local ow = ok and Game.overworld or nil
|
||||||
|
if not (ow and ow.map and ow.camera and Voxel.active()
|
||||||
|
and Voxel3D.available()) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local vw, vh = 320, 288
|
||||||
|
pcall(function() vw, vh = Game.renderer:worldViewSize() end)
|
||||||
|
|
||||||
|
-- Whatever the camera does, the CARDS hold the top rung's near-upright
|
||||||
|
-- lean: a head that roams has no one pitch for them to match, and 75
|
||||||
|
-- degrees is the pose that reads as "standing" from anywhere. Cleared
|
||||||
|
-- on shutdown, so the flat screen leans with the rung as ever.
|
||||||
|
VoxelScene.spriteLean = math.rad(75)
|
||||||
|
|
||||||
|
local pivot, anchor, scale, mountYaw
|
||||||
|
local fp = FirstPerson.engaged()
|
||||||
|
local battle, battleFloor
|
||||||
|
if camMode == "battle" then battle, battleFloor = battleStage() end
|
||||||
|
if battle then
|
||||||
|
-- the over-the-shoulder seat the flat battle shot stands in, pulled
|
||||||
|
-- close enough for a headset's own lens (see VRRig.battleMount), at
|
||||||
|
-- life scale, turned to face the arena
|
||||||
|
local rec = BattleCam.rig(battle, battleFloor)
|
||||||
|
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
|
||||||
|
anchor = { 0, 0, 0 }
|
||||||
|
scale = VRRig.FP_SCALE
|
||||||
|
elseif fp then
|
||||||
|
local p = ow.player
|
||||||
|
local gh = 0
|
||||||
|
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
|
||||||
|
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
|
||||||
|
anchor = { 0, 0, 0 }
|
||||||
|
scale = VRRig.FP_SCALE
|
||||||
|
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
|
||||||
|
-- turns through
|
||||||
|
if fpYawOff ~= 0 then mountYaw = fpYawOff end
|
||||||
|
-- the HMD is the head: its yaw and pitch (plus the snaps) become
|
||||||
|
-- FirstPerson's, so FreeMove walks where you look and A talks to
|
||||||
|
-- what you face
|
||||||
|
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
|
||||||
|
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
|
||||||
|
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
|
||||||
|
math.min(FirstPerson.PITCH_DOWN, pitch))
|
||||||
|
else
|
||||||
|
-- The table presents the world exactly as the flat screen does at
|
||||||
|
-- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle
|
||||||
|
-- (stepping rungs re-tilts the model, easing with the rung tween),
|
||||||
|
-- at the scale that reproduces the flat framing -- then the player's
|
||||||
|
-- own adjustments go on top: the stick's zoom, the grip's height.
|
||||||
|
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
|
||||||
|
anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff)
|
||||||
|
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The pokedex, on the tracked left hand, under this very mapping --
|
||||||
|
-- but only where it earns its keep: FIRST PERSON, where its screen is
|
||||||
|
-- every menu, dialog and wipe the flat screen shows (and the floating
|
||||||
|
-- billboard is retired outright -- see updateQuad), and the BATTLE
|
||||||
|
-- seat, where its screen is the fight's own 2D scene. The diorama
|
||||||
|
-- does without: a hand-sized device hovering over a tabletop town is
|
||||||
|
-- clutter, and the panel serves there. No hand tracked, no device.
|
||||||
|
local hand = ctl and ctl.handl or nil
|
||||||
|
if hand and (battle or fp) then
|
||||||
|
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
|
||||||
|
if uiShowing() then
|
||||||
|
local scr = dexScreen()
|
||||||
|
if scr then
|
||||||
|
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
|
||||||
|
end
|
||||||
|
elseif V.require("Horde").active then
|
||||||
|
-- HORDE MODE's readout, on the device already in the player's left
|
||||||
|
-- hand. It cannot be a flat overlay: the eye buffers have
|
||||||
|
-- ASYMMETRIC frusta, so the same canvas pixel is a different ANGLE
|
||||||
|
-- in each eye and a 2D HUD drawn into both tears down the middle.
|
||||||
|
-- The Pokedex is real geometry both eyes see from their own
|
||||||
|
-- position, so the stereo is correct by construction -- and it is
|
||||||
|
-- already tracked, already lit, and already the thing this mod
|
||||||
|
-- puts information on. (The gun wore it briefly and that was
|
||||||
|
-- worse: a screen on the slide sits exactly where the iron sights
|
||||||
|
-- need to be looked through.)
|
||||||
|
--
|
||||||
|
-- The UV rect goes over the usual way up: v = 0 at the TOP, which
|
||||||
|
-- is how the device's screen quad reads every other texture it
|
||||||
|
-- wears. An inverted rect was tried first, on the theory that a
|
||||||
|
-- self-drawn canvas samples from the bottom -- it does not here,
|
||||||
|
-- and it stood the readout on its head.
|
||||||
|
local tex = V.require("HordeHud").panelTexture()
|
||||||
|
if tex then Pokedex.screen(tex, 0, 0, 1, 1) end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Pokedex.clear()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- and the horde's gun on the tracked RIGHT hand, under the same
|
||||||
|
-- mapping. The AIM pose where the runtime offers one -- the barrel
|
||||||
|
-- should point where the player is pointing, not along their wrist --
|
||||||
|
-- and the grip pose as the fallback. Placed here rather than in the
|
||||||
|
-- draw because the shot is traced down the model's own axis, so the
|
||||||
|
-- matrix has to exist before anything can be hit with it.
|
||||||
|
do
|
||||||
|
local HordeGun = V.require("HordeGun")
|
||||||
|
local right = ctl and (ctl.aimr or ctl.handr) or nil
|
||||||
|
if right and fp and not battle and V.require("Horde").active then
|
||||||
|
HordeGun.place(right, pivot, anchor, scale, mountYaw)
|
||||||
|
else
|
||||||
|
HordeGun.clear()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local eyes = {}
|
||||||
|
for i = 1, 2 do
|
||||||
|
local v = views[i]
|
||||||
|
eyes[i] = {
|
||||||
|
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
|
||||||
|
w = v.w, h = v.h,
|
||||||
|
slot = i == 1 and "vrL" or "vrR",
|
||||||
|
-- the battle seat is a placed shot, not the first-person rig: the
|
||||||
|
-- cards keep their stage lean rather than yawing at this eye, and
|
||||||
|
-- the player's own card stays visible in it
|
||||||
|
adopt = not battle,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
eyes.cx, eyes.cy = pivot[1], pivot[3]
|
||||||
|
|
||||||
|
local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
|
||||||
|
VR.paletteFor, eyes)
|
||||||
|
if not (okR and type(canvases) == "table" and canvases[1] and canvases[2])
|
||||||
|
then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the snap's fade, over the finished eyes: plain black at this moment's
|
||||||
|
-- strength, drawn before the blit so the headset never sees the swap.
|
||||||
|
-- A full-frame fill is the ONE 2D thing that is safe to draw into an
|
||||||
|
-- eye buffer -- it covers everything, so it does not matter that the
|
||||||
|
-- two frusta disagree about where any given pixel points.
|
||||||
|
if fadeAlpha > 0 then
|
||||||
|
pcall(function()
|
||||||
|
for i = 1, 2 do
|
||||||
|
local c = canvases[i]
|
||||||
|
love.graphics.setCanvas(c)
|
||||||
|
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
|
||||||
|
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
|
||||||
|
end
|
||||||
|
love.graphics.setCanvas()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, 2 do
|
||||||
|
local canvas = canvases[i]
|
||||||
|
local tex, tw, th = VRXR.acquireEye(i)
|
||||||
|
if tex then
|
||||||
|
local fbo = fboCache[canvas]
|
||||||
|
if not fbo then
|
||||||
|
fbo = VRGL.canvasFBO(canvas)
|
||||||
|
fboCache[canvas] = fbo
|
||||||
|
end
|
||||||
|
if fbo then
|
||||||
|
VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(),
|
||||||
|
tex, tw, th)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
VRXR.releaseEye(i)
|
||||||
|
end
|
||||||
|
mirrorSrc = canvases[1]
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the UI panel
|
||||||
|
|
||||||
|
-- Whether the flat screen is showing something the world pass cannot: a
|
||||||
|
-- menu, a dialog, a battle, a transition wipe -- or everything, when the
|
||||||
|
-- world pass is off entirely.
|
||||||
|
local function wantQuad(worldUp)
|
||||||
|
if not worldUp then return true end
|
||||||
|
return uiShowing()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updateQuad(worldUp, fp)
|
||||||
|
if not wantQuad(worldUp) then return nil end
|
||||||
|
-- Wherever the pokedex is up and lit -- first person's menus, the
|
||||||
|
-- battle seat's 2D fight -- it IS the screen, and no floating
|
||||||
|
-- billboard is submitted at all. (No tracked left hand still gets
|
||||||
|
-- the panel: the UI must be readable somewhere.)
|
||||||
|
if Pokedex.frame and Pokedex.frame.tex then return nil end
|
||||||
|
local tex, qw, qh = VRXR.acquireQuad()
|
||||||
|
if not tex then return nil end
|
||||||
|
local ww, wh = qw, qh
|
||||||
|
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
|
||||||
|
-- The panel wears the GB FRAME, not the window: everything the flat
|
||||||
|
-- screen has to say lives in the 160x144 letterbox (the world around
|
||||||
|
-- it is just the mirror's picture). The frame region is blitted OUT
|
||||||
|
-- of the window and SCALED into the swapchain image -- never copied
|
||||||
|
-- pixel-for-pixel, because the swapchain's size is fixed at session
|
||||||
|
-- start and a fullscreened window outgrows it, running the frame (and
|
||||||
|
-- the START menu flush with its right edge) off the copy. Scaled, the
|
||||||
|
-- panel shows the identical picture at the identical ratio whatever
|
||||||
|
-- size the window is. Source coordinates are GL's, origin bottom-left.
|
||||||
|
local crop = nil
|
||||||
|
local copied = false
|
||||||
|
pcall(function()
|
||||||
|
local BattleScene = V.require("BattleScene")
|
||||||
|
local lx, ly, s = BattleScene.letterbox()
|
||||||
|
local wpx = math.ceil(BattleScene.GB_W * s)
|
||||||
|
local hpx = math.ceil(BattleScene.GB_H * s)
|
||||||
|
local sx = math.max(0, math.floor(lx))
|
||||||
|
local sy = math.max(0, math.floor(wh - ly - hpx))
|
||||||
|
wpx = math.min(wpx, ww - sx)
|
||||||
|
hpx = math.min(hpx, wh - sy)
|
||||||
|
if wpx < 1 or hpx < 1 then return end
|
||||||
|
-- fitted to the swapchain image at the REGION's own aspect: the
|
||||||
|
-- crop then presents exactly that rect, so the panel's shape is the
|
||||||
|
-- GB frame's at any window and any swapchain size
|
||||||
|
local fit = math.min(qw / wpx, qh / hpx)
|
||||||
|
local dw = math.max(1, math.floor(wpx * fit))
|
||||||
|
local dh = math.max(1, math.floor(hpx * fit))
|
||||||
|
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
|
||||||
|
copied = true
|
||||||
|
crop = { 0, 0, dw, dh }
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not copied then
|
||||||
|
-- no letterbox to cut (or the blit refused): the old whole-window
|
||||||
|
-- copy, clamped, is still a readable panel
|
||||||
|
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
|
||||||
|
end
|
||||||
|
VRXR.releaseQuad()
|
||||||
|
local base = fp and QUAD_FP or QUAD_DIORAMA
|
||||||
|
if not crop then return base end
|
||||||
|
return { pos = base.pos, width = base.width, crop = crop }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the controllers
|
||||||
|
--
|
||||||
|
-- The mapping the mod ships (rebindable in the runtime's own UI):
|
||||||
|
--
|
||||||
|
-- both modes left stick moves (through the engine's own stick path,
|
||||||
|
-- so it grid-walks the diorama and free-walks 1ST);
|
||||||
|
-- A/B are A/B; either trigger is START; clicking the
|
||||||
|
-- LEFT stick steps the VOXEL angle ladder exactly as
|
||||||
|
-- the "3" key (and the pad's SELECT) does.
|
||||||
|
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
|
||||||
|
-- diorama only right stick up/down zooms the model; squeezing a grip
|
||||||
|
-- and moving that hand up or down drags the whole table
|
||||||
|
-- with it.
|
||||||
|
--
|
||||||
|
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
|
||||||
|
-- no controller button does it. VR.leave below stays as the API for it.
|
||||||
|
|
||||||
|
-- The left stick click makes EXACTLY the step the "3" key makes: one
|
||||||
|
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
|
||||||
|
-- TILT and GBC FX in the save -- by calling the very function the key
|
||||||
|
-- and the pad's SELECT button already share. main.lua installs it below
|
||||||
|
-- (cycleVoxel is a local of that file); the free-roam gate is the
|
||||||
|
-- registry's own, inside it, so a click over a menu or mid-warp is a
|
||||||
|
-- no-op exactly like the key.
|
||||||
|
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
|
||||||
|
|
||||||
|
function VR.stepView()
|
||||||
|
pcall(function()
|
||||||
|
if not VR.cycleVoxel then return end
|
||||||
|
VR.cycleVoxel(require("src.core.Game"))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Leave VR: the VR row toggled back off and persisted, exactly as if
|
||||||
|
-- stepped on the OPTIONS menu, so the next update tears the session down
|
||||||
|
-- and the flat screen takes the picture back. Deliberately bound to NO
|
||||||
|
-- controller button (a click that ejects you from the headset is a trap
|
||||||
|
-- mid-fight); kept as the one programmatic door out.
|
||||||
|
function VR.leave()
|
||||||
|
pcall(function()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
VR.setting:setIndex(VR.setting:read() + 1, Game)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setGB(inp, btn, down)
|
||||||
|
if down and not held[btn] then
|
||||||
|
held[btn] = true
|
||||||
|
inp:overlayPressed(btn)
|
||||||
|
elseif not down and held[btn] then
|
||||||
|
held[btn] = nil
|
||||||
|
inp:overlayReleased(btn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function driveControls(ctl, dt, fp)
|
||||||
|
if not ctl then
|
||||||
|
releaseInputs()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
if not (ok and Game.input) then return end
|
||||||
|
local inp = Game.input
|
||||||
|
|
||||||
|
-- HORDE MODE re-reads the right hand as a weapon: the trigger fires
|
||||||
|
-- (its own OpenXR action, suggested alongside START on the same input
|
||||||
|
-- -- see VRXR.setupInput), and B reloads. START is dropped rather than
|
||||||
|
-- forwarded, because the mode does not pause. Everything else -- the
|
||||||
|
-- stick's walk, the snap turn, A -- keeps working, so the player can
|
||||||
|
-- still move and look while they are being chased.
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
if Horde.playing() then
|
||||||
|
local Gun = V.require("HordeGun")
|
||||||
|
if ctl.fireChanged and ctl.fire then Gun.fire() end
|
||||||
|
if ctl.bChanged and ctl.b then Gun.reload() end
|
||||||
|
setGB(inp, "a", ctl.a)
|
||||||
|
setGB(inp, "b", false)
|
||||||
|
setGB(inp, "start", false)
|
||||||
|
else
|
||||||
|
setGB(inp, "a", ctl.a)
|
||||||
|
setGB(inp, "b", ctl.b)
|
||||||
|
setGB(inp, "start", ctl.start)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the left stick, through the engine's OWN stick handler: it quantises
|
||||||
|
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
|
||||||
|
-- the same raw pair for the free walk. OpenXR's +Y is up; the engine's
|
||||||
|
-- lefty is +down.
|
||||||
|
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
|
||||||
|
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
|
||||||
|
|
||||||
|
-- the left stick click: the VOXEL ladder ordinarily, and the way out of
|
||||||
|
-- horde mode while it runs (the rung is locked there, so the click has
|
||||||
|
-- nothing else to do, and a headset has no ESCAPE key)
|
||||||
|
if ctl.toggleChanged and ctl.toggle then
|
||||||
|
if Horde.active then Horde.askExit() else VR.stepView() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
|
||||||
|
-- rate -- hold and the world rotates under you -- and OFF (the
|
||||||
|
-- default) makes it a 45-degree snap per flick: see the row's own
|
||||||
|
-- reasoning where it is declared. Either way the offset turns the
|
||||||
|
-- MAPPING, so the eyes, the walk direction, the pokedex and the gun
|
||||||
|
-- all agree about which way the world now faces.
|
||||||
|
if fp and camMode ~= "battle" and VR.smoothTurn:get() == true then
|
||||||
|
local sx = ctl.lookX or 0
|
||||||
|
local a = math.abs(sx)
|
||||||
|
if a > 0.2 then
|
||||||
|
a = (a - 0.2) / 0.8
|
||||||
|
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||||
|
-- pushed right subtracts -- the same sign the snap below uses
|
||||||
|
fpYawOff = wrapPi(fpYawOff
|
||||||
|
- (sx > 0 and 1 or -1) * a * a
|
||||||
|
* VR.SMOOTH_TURN_RATE * (dt or 0))
|
||||||
|
end
|
||||||
|
snapArmed = true -- so a switch back to snap mid-flick re-arms
|
||||||
|
elseif fp and camMode ~= "battle" then
|
||||||
|
local sx = ctl.lookX or 0
|
||||||
|
if math.abs(sx) > 0.65 then
|
||||||
|
if snapArmed then
|
||||||
|
snapArmed = false
|
||||||
|
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||||
|
-- pushed right subtracts
|
||||||
|
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
|
||||||
|
end
|
||||||
|
elseif math.abs(sx) < 0.35 then
|
||||||
|
snapArmed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not fp and camMode ~= "battle" then
|
||||||
|
local zy = ctl.lookY or 0
|
||||||
|
if math.abs(zy) > 0.15 then
|
||||||
|
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
|
||||||
|
end
|
||||||
|
-- the grab-drag: while a grip is squeezed, the table follows that
|
||||||
|
-- hand's height, metre for metre
|
||||||
|
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
|
||||||
|
local y = (gr >= gl) and ctl.handrY or ctl.handlY
|
||||||
|
if math.max(gl, gr) > 0.6 and y then
|
||||||
|
if lastHandY then
|
||||||
|
heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY)))
|
||||||
|
end
|
||||||
|
lastHandY = y
|
||||||
|
else
|
||||||
|
lastHandY = nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
lastHandY = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the per-frame drive
|
||||||
|
|
||||||
|
function VR.update(dt)
|
||||||
|
local on = VR.enabled()
|
||||||
|
if not on then
|
||||||
|
if wasOn then
|
||||||
|
shutdown("off")
|
||||||
|
failed = nil
|
||||||
|
end
|
||||||
|
wasOn = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not wasOn then failed = nil end -- a fresh toggle earns a fresh try
|
||||||
|
wasOn = true
|
||||||
|
if failed then return end
|
||||||
|
|
||||||
|
if not started then
|
||||||
|
local qw, qh = 1024, 768
|
||||||
|
pcall(function() qw, qh = love.graphics.getPixelDimensions() end)
|
||||||
|
if VRXR.start(qw, qh) then
|
||||||
|
started = true
|
||||||
|
status = "session created"
|
||||||
|
print("[DRAMATIC_SHAPE] VR: " .. VRXR.status())
|
||||||
|
else
|
||||||
|
failed = VRXR.status()
|
||||||
|
print("[DRAMATIC_SHAPE] VR unavailable: " .. failed
|
||||||
|
.. " -- fix that, then toggle the VR row to retry")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not VRXR.poll() then
|
||||||
|
-- the runtime took the session away (headset off, runtime shut down)
|
||||||
|
shutdown("session lost")
|
||||||
|
failed = "session lost -- toggle VR off and on to retry"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not VRXR.isRunning() then return end
|
||||||
|
|
||||||
|
-- the headset paces the app now; vsync would fight it
|
||||||
|
if savedVsync == nil then
|
||||||
|
savedVsync = 1
|
||||||
|
pcall(function() savedVsync = love.window.getVSync() end)
|
||||||
|
pcall(love.window.setVSync, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the battle camera holds still for as long as a headset is watching:
|
||||||
|
-- its drift is a flat screen's depth cue, and a swaying picture inside
|
||||||
|
-- VR reads as the world lurching
|
||||||
|
BattleCam.still = true
|
||||||
|
|
||||||
|
-- The battle snap's fade: while the camera the frame WANTS is not the
|
||||||
|
-- one it is showing, black rises; at full black the mount swaps; then
|
||||||
|
-- black lifts. Driven here, on game time, so a fight that ends during
|
||||||
|
-- the fade just turns it around.
|
||||||
|
local want = battleStage() and "battle" or "explore"
|
||||||
|
if want ~= camMode then
|
||||||
|
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
|
||||||
|
if fadeAlpha >= 1 then camMode = want end
|
||||||
|
else
|
||||||
|
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
|
||||||
|
end
|
||||||
|
|
||||||
|
local time, should = VRXR.waitFrame()
|
||||||
|
if not time then return end
|
||||||
|
|
||||||
|
-- the controllers, before the world renders: the frame the toggle
|
||||||
|
-- flips rungs on should be the frame that renders the new rig. The
|
||||||
|
-- state is kept in hand for renderWorld too -- the pokedex stands on
|
||||||
|
-- the same frame's left-hand pose.
|
||||||
|
local ctl = VRXR.input(time)
|
||||||
|
driveControls(ctl, dt, FirstPerson.engaged())
|
||||||
|
|
||||||
|
local worldUp = false
|
||||||
|
if should then
|
||||||
|
local views = VRXR.locateViews(time)
|
||||||
|
if views then
|
||||||
|
worldUp = renderWorld(views, ctl)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
|
||||||
|
VRXR.endFrame(time, worldUp or nil, quadPose)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the window while a headset owns the picture
|
||||||
|
|
||||||
|
-- The flat window becomes the mirror: the left eye, fitted to the window.
|
||||||
|
-- Returns nil when there is nothing to mirror (the caller draws the flat
|
||||||
|
-- path as ever).
|
||||||
|
function VR.mirror(sw, sh)
|
||||||
|
if not (VR.active() and mirrorSrc) then return nil end
|
||||||
|
if not (mirrorCanvas and mirrorCanvas:getWidth() == sw
|
||||||
|
and mirrorCanvas:getHeight() == sh) then
|
||||||
|
local ok, c = pcall(love.graphics.newCanvas, sw, sh)
|
||||||
|
if not ok then return nil end
|
||||||
|
mirrorCanvas = c
|
||||||
|
end
|
||||||
|
local ok = pcall(function()
|
||||||
|
love.graphics.setCanvas(mirrorCanvas)
|
||||||
|
love.graphics.clear(0, 0, 0, 1)
|
||||||
|
local mw, mh = mirrorSrc:getDimensions()
|
||||||
|
local s = math.min(sw / mw, sh / mh)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s)
|
||||||
|
love.graphics.setCanvas()
|
||||||
|
end)
|
||||||
|
pcall(love.graphics.setCanvas)
|
||||||
|
return ok and mirrorCanvas or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- window resize, hot reload: the eye canvases are Voxel3D's and go with
|
||||||
|
-- its invalidate; ours is the mirror and the FBO ids learned from dead
|
||||||
|
-- canvases
|
||||||
|
function VR.invalidate()
|
||||||
|
if mirrorCanvas and mirrorCanvas.release then
|
||||||
|
pcall(mirrorCanvas.release, mirrorCanvas)
|
||||||
|
end
|
||||||
|
mirrorCanvas, mirrorSrc = nil, nil
|
||||||
|
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
|
||||||
|
dexCanvas = nil
|
||||||
|
Pokedex.invalidate()
|
||||||
|
V.require("HordeGun").invalidate()
|
||||||
|
V.require("HordeHud").invalidate()
|
||||||
|
for k in pairs(fboCache) do fboCache[k] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
return VR
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
-- VR: the raw OpenGL this mod is otherwise proud to never need.
|
||||||
|
--
|
||||||
|
-- OpenXR hands over its swapchain images as GL TEXTURE IDS, and LOVE never
|
||||||
|
-- exposes the GL names behind its own canvases -- so getting a rendered
|
||||||
|
-- eye from a love Canvas into a headset means dropping below LOVE for a
|
||||||
|
-- few calls a frame: discover the canvas's framebuffer, blit it into the
|
||||||
|
-- swapchain texture, and put the pipeline back exactly as LOVE believes it
|
||||||
|
-- to be. Everything here is that, and only that.
|
||||||
|
--
|
||||||
|
-- Three rules keep this safe:
|
||||||
|
--
|
||||||
|
-- discovery over spelunking. The canvas's FBO id is read from the
|
||||||
|
-- driver with documented queries (bind the canvas THROUGH LOVE, ask
|
||||||
|
-- GL_DRAW_FRAMEBUFFER_BINDING) rather than from LOVE's internals, so a
|
||||||
|
-- LOVE patch cannot move it out from under us.
|
||||||
|
--
|
||||||
|
-- restore what LOVE caches. LOVE tracks the bound framebuffer and skips
|
||||||
|
-- redundant binds, so raw binds must end back at the exact binding LOVE
|
||||||
|
-- thinks is current -- the default framebuffer, 0, since every call
|
||||||
|
-- here runs between LOVE passes -- or LOVE's next draw lands in ours.
|
||||||
|
--
|
||||||
|
-- pcall at the rim, ffi inside. The FFI setup can fail (headless, a GL
|
||||||
|
-- context without FBO entry points); it fails ONCE, at load(), and
|
||||||
|
-- callers see `nil, reason` rather than an error mid-frame.
|
||||||
|
|
||||||
|
local VRGL = {}
|
||||||
|
|
||||||
|
local ffi = nil
|
||||||
|
local gl = nil -- opengl32 exports (GL 1.1 + wgl)
|
||||||
|
local ext = {} -- post-1.1 entry points via wglGetProcAddress
|
||||||
|
local ready = false
|
||||||
|
local reason = nil
|
||||||
|
local fbo = nil -- our scratch framebuffer, made once
|
||||||
|
|
||||||
|
local GL = {
|
||||||
|
FRAMEBUFFER = 0x8D40,
|
||||||
|
READ_FRAMEBUFFER = 0x8CA8,
|
||||||
|
DRAW_FRAMEBUFFER = 0x8CA9,
|
||||||
|
DRAW_FRAMEBUFFER_BINDING = 0x8CA6,
|
||||||
|
COLOR_ATTACHMENT0 = 0x8CE0,
|
||||||
|
COLOR_BUFFER_BIT = 0x4000,
|
||||||
|
NEAREST = 0x2600,
|
||||||
|
LINEAR = 0x2601,
|
||||||
|
TEXTURE_2D = 0x0DE1,
|
||||||
|
FRONT = 0x0404,
|
||||||
|
BACK = 0x0405,
|
||||||
|
}
|
||||||
|
VRGL.GL = GL
|
||||||
|
|
||||||
|
local CDEF = [[
|
||||||
|
typedef void (__stdcall *PROC)();
|
||||||
|
void* wglGetCurrentDC(void);
|
||||||
|
void* wglGetCurrentContext(void);
|
||||||
|
PROC wglGetProcAddress(const char*);
|
||||||
|
unsigned int glGetError(void);
|
||||||
|
void glGetIntegerv(unsigned int pname, int* params);
|
||||||
|
void glReadBuffer(unsigned int mode);
|
||||||
|
void glFlush(void);
|
||||||
|
void glCopyTexSubImage2D(unsigned int target, int level, int xoffset,
|
||||||
|
int yoffset, int x, int y, int width, int height);
|
||||||
|
void glBindTexture(unsigned int target, unsigned int texture);
|
||||||
|
typedef void (__stdcall *pfn_glBindFramebuffer)(unsigned int, unsigned int);
|
||||||
|
typedef void (__stdcall *pfn_glGenFramebuffers)(int, unsigned int*);
|
||||||
|
typedef void (__stdcall *pfn_glDeleteFramebuffers)(int, const unsigned int*);
|
||||||
|
typedef void (__stdcall *pfn_glFramebufferTexture2D)(unsigned int,
|
||||||
|
unsigned int, unsigned int, unsigned int, int);
|
||||||
|
typedef void (__stdcall *pfn_glBlitFramebuffer)(int, int, int, int,
|
||||||
|
int, int, int, int, unsigned int, unsigned int);
|
||||||
|
]]
|
||||||
|
|
||||||
|
-- One-time FFI setup. Idempotent, and every path out records why it
|
||||||
|
-- stopped, so VR's status line can say something better than "no".
|
||||||
|
function VRGL.load()
|
||||||
|
if ready then return true end
|
||||||
|
if reason then return false, reason end
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
ffi = require("ffi")
|
||||||
|
-- cdef survives a reload; redefinition is the only error worth eating
|
||||||
|
pcall(ffi.cdef, CDEF)
|
||||||
|
gl = ffi.load("opengl32")
|
||||||
|
local function proc(name, typ)
|
||||||
|
local p = gl.wglGetProcAddress(name)
|
||||||
|
if p == nil then error(name .. " not exposed by this GL context", 0) end
|
||||||
|
return ffi.cast(typ, p)
|
||||||
|
end
|
||||||
|
ext.glBindFramebuffer = proc("glBindFramebuffer", "pfn_glBindFramebuffer")
|
||||||
|
ext.glGenFramebuffers = proc("glGenFramebuffers", "pfn_glGenFramebuffers")
|
||||||
|
ext.glDeleteFramebuffers =
|
||||||
|
proc("glDeleteFramebuffers", "pfn_glDeleteFramebuffers")
|
||||||
|
ext.glFramebufferTexture2D =
|
||||||
|
proc("glFramebufferTexture2D", "pfn_glFramebufferTexture2D")
|
||||||
|
ext.glBlitFramebuffer = proc("glBlitFramebuffer", "pfn_glBlitFramebuffer")
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
reason = "GL interop unavailable: " .. tostring(err)
|
||||||
|
return false, reason
|
||||||
|
end
|
||||||
|
ready = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The window's device and GL contexts, which the OpenXR session binds to.
|
||||||
|
function VRGL.contexts()
|
||||||
|
if not VRGL.load() then return nil, nil end
|
||||||
|
return gl.wglGetCurrentDC(), gl.wglGetCurrentContext()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The GL framebuffer behind a LOVE canvas. Bound through LOVE (so LOVE's
|
||||||
|
-- own cache stays truthful), read from the driver, then released.
|
||||||
|
function VRGL.canvasFBO(canvas)
|
||||||
|
if not VRGL.load() then return nil end
|
||||||
|
local id = nil
|
||||||
|
local ok = pcall(function()
|
||||||
|
love.graphics.setCanvas(canvas)
|
||||||
|
local out = ffi.new("int[1]")
|
||||||
|
gl.glGetIntegerv(GL.DRAW_FRAMEBUFFER_BINDING, out)
|
||||||
|
id = out[0]
|
||||||
|
love.graphics.setCanvas()
|
||||||
|
end)
|
||||||
|
pcall(love.graphics.setCanvas)
|
||||||
|
return ok and id or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Blit a LOVE canvas's pixels into a GL texture (an XR swapchain image),
|
||||||
|
-- flipped vertically on the way: LOVE renders its canvases y-down, GL
|
||||||
|
-- textures composite y-up, and the blit is the one place the two meet.
|
||||||
|
--
|
||||||
|
-- `srcFBO` comes from canvasFBO (cache it -- it is stable for the
|
||||||
|
-- canvas's lifetime). Ends with framebuffer 0 bound, which is the binding
|
||||||
|
-- LOVE believes in between its passes.
|
||||||
|
function VRGL.blitToTexture(srcFBO, sw, sh, tex, tw, th)
|
||||||
|
if not ready then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
if not fbo then
|
||||||
|
local out = ffi.new("unsigned int[1]")
|
||||||
|
ext.glGenFramebuffers(1, out)
|
||||||
|
fbo = out[0]
|
||||||
|
end
|
||||||
|
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
|
||||||
|
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
|
||||||
|
GL.TEXTURE_2D, tex, 0)
|
||||||
|
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, srcFBO)
|
||||||
|
ext.glBlitFramebuffer(0, sh, sw, 0, 0, 0, tw, th,
|
||||||
|
GL.COLOR_BUFFER_BIT, GL.LINEAR)
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
end)
|
||||||
|
if not ok then pcall(function() ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) end) end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Copy the WINDOW's currently displayed image (the front buffer -- the
|
||||||
|
-- back buffer's contents are undefined after a swap) into a GL texture:
|
||||||
|
-- the UI quad the headset floats in front of the world. Restores the read
|
||||||
|
-- buffer to BACK, the default LOVE never changes.
|
||||||
|
function VRGL.copyFrontBuffer(tex, w, h)
|
||||||
|
if not ready then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.FRONT)
|
||||||
|
gl.glBindTexture(GL.TEXTURE_2D, tex)
|
||||||
|
gl.glCopyTexSubImage2D(GL.TEXTURE_2D, 0, 0, 0, 0, 0, w, h)
|
||||||
|
gl.glBindTexture(GL.TEXTURE_2D, 0)
|
||||||
|
gl.glReadBuffer(GL.BACK)
|
||||||
|
end)
|
||||||
|
if not ok then pcall(function() gl.glReadBuffer(GL.BACK) end) end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Blit a REGION of the window's front buffer into a GL texture (an XR
|
||||||
|
-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is
|
||||||
|
-- the panel's route: the whole-window copy above is pixel-for-pixel, so
|
||||||
|
-- a window larger than the swapchain image simply ran off its edges --
|
||||||
|
-- fullscreen cut the GB frame's own menu off the panel. A scaled blit
|
||||||
|
-- has no such cliff: the letterbox region lands whole at the texture's
|
||||||
|
-- own resolution whatever size the window is. Source coordinates are GL
|
||||||
|
-- window space, origin bottom-left; LINEAR, because the region rarely
|
||||||
|
-- matches the target size exactly and dropped rows read worse than a
|
||||||
|
-- soft one. Restores the read buffer and framebuffer LOVE believes in.
|
||||||
|
function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh)
|
||||||
|
if not ready then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
if not fbo then
|
||||||
|
local out = ffi.new("unsigned int[1]")
|
||||||
|
ext.glGenFramebuffers(1, out)
|
||||||
|
fbo = out[0]
|
||||||
|
end
|
||||||
|
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.FRONT)
|
||||||
|
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
|
||||||
|
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
|
||||||
|
GL.TEXTURE_2D, tex, 0)
|
||||||
|
ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh,
|
||||||
|
GL.COLOR_BUFFER_BIT, GL.LINEAR)
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.BACK)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
pcall(function()
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.BACK)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from
|
||||||
|
-- canvasFBO), flipped so the canvas reads top-down exactly like the
|
||||||
|
-- window: what LOVE then draws from that canvas at (0,0) is the screen,
|
||||||
|
-- row for row. The battle's VR quad is the caller: it needs the screen as
|
||||||
|
-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a
|
||||||
|
-- finished texture -- copyFrontBuffer above is for the finished case.
|
||||||
|
function VRGL.copyFrontToCanvas(dstFBO, w, h)
|
||||||
|
if not ready then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.FRONT)
|
||||||
|
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO)
|
||||||
|
ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0,
|
||||||
|
GL.COLOR_BUFFER_BIT, GL.NEAREST)
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.BACK)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
pcall(function()
|
||||||
|
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||||
|
gl.glReadBuffer(GL.BACK)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
function VRGL.status()
|
||||||
|
if ready then return "ok" end
|
||||||
|
return reason or "not loaded"
|
||||||
|
end
|
||||||
|
|
||||||
|
return VRGL
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
-- VR: the pose arithmetic -- how a headset eye becomes one of this mod's
|
||||||
|
-- cameras. Pure math on purpose: no FFI, no OpenXR types, nothing a
|
||||||
|
-- headless test cannot hold still. Everything device-shaped stays in
|
||||||
|
-- VRXR/VRGL; everything world-shaped is here.
|
||||||
|
--
|
||||||
|
-- Two ways the world can sit around a headset, and they mirror the VOXEL
|
||||||
|
-- ladder exactly:
|
||||||
|
--
|
||||||
|
-- DIORAMA every orbit rung. The map is a tabletop miniature: a point
|
||||||
|
-- of the world (the view centre) is pinned VIEW_DIST away
|
||||||
|
-- along the rung's own viewing angle (dioramaAnchor), at the
|
||||||
|
-- scale that reproduces the flat screen's framing
|
||||||
|
-- (dioramaScale) -- so at rest the model presents exactly as
|
||||||
|
-- the standard view does, and the head moves freely around it
|
||||||
|
-- -- lean in and the town grows, walk around the table and
|
||||||
|
-- see the far side of the buildings honest occlusion has been
|
||||||
|
-- hiding.
|
||||||
|
--
|
||||||
|
-- FIRST_PERSON the 1ST rung. The player's head is pinned to where the
|
||||||
|
-- headset started, at FP_SCALE, so a 16-pixel person stands
|
||||||
|
-- about 1.6 m tall and a cell is a stride. The HMD's own
|
||||||
|
-- orientation becomes FirstPerson's yaw and pitch, so movement
|
||||||
|
-- stays "push forward, go where you look" through the same
|
||||||
|
-- FreeMove the flat screen uses.
|
||||||
|
--
|
||||||
|
-- SPACES AND UNITS. OpenXR LOCAL space is metres, +Y up, -Z the way the
|
||||||
|
-- head faced at session start. World space is world PIXELS, +Y up, +Z
|
||||||
|
-- south. The two are aligned axis-for-axis -- "away from you" is north --
|
||||||
|
-- so the whole mapping is one translate-and-scale:
|
||||||
|
--
|
||||||
|
-- worldFromXr(p) = pivot + s * (p - anchor)
|
||||||
|
--
|
||||||
|
-- with `pivot` a world point, `anchor` the LOCAL-space point pinned to it,
|
||||||
|
-- and `s` the scale in px/m. An eye's camera is then
|
||||||
|
--
|
||||||
|
-- worldFromEye = T(pivot) * S(s) * T(-anchor) * T(pose.pos) * R(pose.q)
|
||||||
|
-- view = the same chain inverted piece by rigid piece
|
||||||
|
--
|
||||||
|
-- and the VIEW deliberately ends in METRES: it un-scales the world, so eye
|
||||||
|
-- space -- where the projection's near and far live -- is real-world
|
||||||
|
-- metres whatever the mode's scale. Depth precision and clip planes stay
|
||||||
|
-- sane at both 10 px/m and 128 px/m.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Mat4 = V.require("Mat4")
|
||||||
|
|
||||||
|
local VRRig = {}
|
||||||
|
|
||||||
|
-- first person's life size: 10 px/m makes a 16 px tile a 1.6 m stride
|
||||||
|
VRRig.FP_SCALE = 10
|
||||||
|
|
||||||
|
-- How far the diorama's pivot sits from the resting head, in metres --
|
||||||
|
-- the arm's-length viewing distance the anchor and the scale below are
|
||||||
|
-- both built around.
|
||||||
|
VRRig.VIEW_DIST = 0.95
|
||||||
|
|
||||||
|
-- Where, in LOCAL metres, the diorama's pivot sits: VIEW_DIST away along
|
||||||
|
-- the RUNG'S OWN viewing angle. The flat screen's camera looks at the
|
||||||
|
-- world `a` radians off vertical; putting the pivot at (-d cos a) below
|
||||||
|
-- and (-d sin a) ahead of the resting head reproduces exactly that line
|
||||||
|
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
|
||||||
|
-- onto 75 and it rises toward eye level, easing between them as the rung
|
||||||
|
-- tween runs. `heightOff` is the grab-drag adjustment, in metres of world
|
||||||
|
-- travel (positive drags the world up).
|
||||||
|
function VRRig.dioramaAnchor(angleRad, heightOff)
|
||||||
|
local d = VRRig.VIEW_DIST
|
||||||
|
return { 0,
|
||||||
|
-d * math.cos(angleRad or 0) + (heightOff or 0),
|
||||||
|
-d * math.sin(angleRad or 0) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The diorama's scale, in world px per metre: the one that makes the
|
||||||
|
-- table subtend the same field the flat screen frames. The flat camera
|
||||||
|
-- fits `vh` world pixels in a lens of focal `focal` (Voxel.FOCAL); at
|
||||||
|
-- VIEW_DIST the same framing needs vh * focal / d pixels to the metre --
|
||||||
|
-- so the resting head sees the standard view's angle AND its apparent
|
||||||
|
-- size, and the zoom rows (which change vh) keep working in VR.
|
||||||
|
function VRRig.dioramaScale(vh, focal)
|
||||||
|
return math.max(16, (vh or 288) * (focal or 1) / VRRig.VIEW_DIST)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- kept as the test suite's fixed example anchor, and as the fallback for
|
||||||
|
-- an angle nobody supplied
|
||||||
|
VRRig.TABLE = { 0, -0.45, -0.75 }
|
||||||
|
|
||||||
|
-- ------- the battle mount
|
||||||
|
--
|
||||||
|
-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same
|
||||||
|
-- line the flat battle camera stands on (eye through focus, so the player's
|
||||||
|
-- mon is near-left and the foe far-right exactly as the flat shot frames
|
||||||
|
-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from
|
||||||
|
-- fifteen metres back, and a headset's lens is its own eyes, so keeping the
|
||||||
|
-- distance would shrink the fight to a stage seen from the back row. 66 px
|
||||||
|
-- is the wide rig's own standing distance: six and a half metres at life
|
||||||
|
-- scale, close enough to fill the view, far enough to hold both mons in it
|
||||||
|
-- -- and short enough to stay inside the small rooms the wide rig exists
|
||||||
|
-- for.
|
||||||
|
VRRig.BATTLE_DIST = 66
|
||||||
|
|
||||||
|
-- Where the head sits for a staged fight, and which way the mapping must
|
||||||
|
-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR
|
||||||
|
-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera
|
||||||
|
-- looks along focus - eye, the resting headset looks along XR -Z (world
|
||||||
|
-- north), and the yaw is what closes that gap.
|
||||||
|
function VRRig.battleMount(eye, focus)
|
||||||
|
local dx = eye[1] - focus[1]
|
||||||
|
local dy = eye[2] - focus[2]
|
||||||
|
local dz = eye[3] - focus[3]
|
||||||
|
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end
|
||||||
|
local k = VRRig.BATTLE_DIST / len
|
||||||
|
-- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming
|
||||||
|
-- that along the horizontal of focus - eye solves to atan2 of eye - focus
|
||||||
|
return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k },
|
||||||
|
math.atan2(dx, dz)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- eye-space clip planes, in metres (see the unit note above)
|
||||||
|
VRRig.NEAR = 0.05
|
||||||
|
VRRig.FAR = 400
|
||||||
|
|
||||||
|
-- ------- one eye's camera
|
||||||
|
|
||||||
|
-- Build the placed-camera record for one eye.
|
||||||
|
--
|
||||||
|
-- pose { pos = {x,y,z} metres, quat = {x,y,z,w} } (OpenXR LOCAL)
|
||||||
|
-- fov { angleLeft, angleRight, angleUp, angleDown } signed radians
|
||||||
|
-- pivot {x,y,z} world px pinned to `anchor`
|
||||||
|
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
|
||||||
|
-- scale world px per metre
|
||||||
|
-- yaw optional turn of the whole mapping about +Y, radians: the
|
||||||
|
-- battle mount faces the resting head at the arena with it.
|
||||||
|
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
|
||||||
|
--
|
||||||
|
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
|
||||||
|
-- eye and focus (for setLook, the water's lean, the sky), fov as a
|
||||||
|
-- vertical span, and the curve declined -- a bent tabletop reads as a
|
||||||
|
-- broken model, and first person already declines it on the flat screen.
|
||||||
|
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
|
||||||
|
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
|
||||||
|
local q = pose.quat
|
||||||
|
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
|
||||||
|
|
||||||
|
-- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot)
|
||||||
|
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
|
||||||
|
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
|
||||||
|
if yaw and yaw ~= 0 then
|
||||||
|
view = Mat4.mul(view, Mat4.rotateY(-yaw))
|
||||||
|
end
|
||||||
|
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
|
||||||
|
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
|
||||||
|
|
||||||
|
local proj = Mat4.fovProjection(fov.angleLeft, fov.angleRight,
|
||||||
|
fov.angleUp, fov.angleDown,
|
||||||
|
VRRig.NEAR, VRRig.FAR)
|
||||||
|
|
||||||
|
-- The eye's RAY FAN, in world axes: the direction a canvas point
|
||||||
|
-- (u, v in 0..1, left-to-right and top-to-bottom) looks along is
|
||||||
|
-- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation
|
||||||
|
-- off this (a real skybox cannot be painted from any per-frame row
|
||||||
|
-- mapping -- that is exact only at the view's own azimuth and swims
|
||||||
|
-- everywhere else). Directions only, so the mapping's scale drops out;
|
||||||
|
-- the yaw must not (the battle mount and the snap turn swing the world).
|
||||||
|
local Rw = R
|
||||||
|
if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end
|
||||||
|
local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight)
|
||||||
|
local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown)
|
||||||
|
-- world columns of the head's rotation: right (X), up (Y), forward (-Z)
|
||||||
|
local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9]
|
||||||
|
local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10]
|
||||||
|
local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11]
|
||||||
|
local skyRay = {
|
||||||
|
base = { fxc + rxc * tl + uxc * tu,
|
||||||
|
fyc + ryc * tl + uyc * tu,
|
||||||
|
fzc + rzc * tl + uzc * tu },
|
||||||
|
du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) },
|
||||||
|
dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- the eye and its forward, in world pixels: worldFromEye applied to the
|
||||||
|
-- origin and to -Z
|
||||||
|
local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3]
|
||||||
|
-- R's third column is the eye's +Z axis; forward is its negation
|
||||||
|
local fx, fy, fz = -R[3], -R[7], -R[11]
|
||||||
|
if yaw and yaw ~= 0 then
|
||||||
|
local c, s = math.cos(yaw), math.sin(yaw)
|
||||||
|
ax, az = c * ax + s * az, -s * ax + c * az
|
||||||
|
fx, fz = c * fx + s * fz, -s * fx + c * fz
|
||||||
|
end
|
||||||
|
local ex = pivot[1] + scale * ax
|
||||||
|
local ey = pivot[2] + scale * ay
|
||||||
|
local ez = pivot[3] + scale * az
|
||||||
|
|
||||||
|
return {
|
||||||
|
view = view,
|
||||||
|
proj = proj,
|
||||||
|
eye = { ex, ey, ez },
|
||||||
|
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
|
||||||
|
fov = fov.angleUp - fov.angleDown,
|
||||||
|
curve = 0,
|
||||||
|
skyRay = skyRay,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the
|
||||||
|
-- same mapping the eyes use -- so the prop is exactly where the hand is,
|
||||||
|
-- whatever mode the mapping is in) composed with the hand's own tracked
|
||||||
|
-- pose. A mesh authored in METRES rides it straight: the mapping's scale
|
||||||
|
-- is what turns metres into world pixels, so the prop keeps its real
|
||||||
|
-- size in the hand at the diorama's scale and at life scale alike.
|
||||||
|
--
|
||||||
|
-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat)
|
||||||
|
function VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||||
|
local m = Mat4.translate(pivot[1], pivot[2], pivot[3])
|
||||||
|
m = Mat4.mul(m, Mat4.scale(scale, scale, scale))
|
||||||
|
if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end
|
||||||
|
m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3]))
|
||||||
|
m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3]))
|
||||||
|
local q = pose.quat
|
||||||
|
return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4]))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The flat compass numbers a head orientation implies, for driving
|
||||||
|
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
|
||||||
|
-- convention (0 south, pi/2 east) and pitch positive-down.
|
||||||
|
function VRRig.headYawPitch(quat)
|
||||||
|
local R = Mat4.fromQuat(quat[1], quat[2], quat[3], quat[4])
|
||||||
|
local fx, fy, fz = -R[3], -R[7], -R[11]
|
||||||
|
local flat = math.sqrt(fx * fx + fz * fz)
|
||||||
|
local yaw = flat > 1e-6 and math.atan2(fx, fz) or 0
|
||||||
|
local pitch = -math.asin(math.max(-1, math.min(1, fy)))
|
||||||
|
return yaw, pitch
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The two pivots. First person pins the player's head; the diorama pins
|
||||||
|
-- the view centre at the ground plane. `gh` is the ground height under
|
||||||
|
-- the player (VoxelScene.groundAt), `eyeH` FirstPerson.EYE_HEIGHT.
|
||||||
|
function VRRig.fpPivot(pxTopLeft, pyTopLeft, gh, eyeH)
|
||||||
|
return { pxTopLeft + 8, (gh or 0) + (eyeH or 13), pyTopLeft + 8 }
|
||||||
|
end
|
||||||
|
|
||||||
|
function VRRig.dioramaPivot(cx, cy)
|
||||||
|
return { cx, 0, cy }
|
||||||
|
end
|
||||||
|
|
||||||
|
return VRRig
|
||||||
+1073
File diff suppressed because it is too large
Load Diff
+670
-21
@@ -30,6 +30,10 @@ local Voxel = V.require("VoxelState")
|
|||||||
local ShadowMap = V.require("ShadowMap")
|
local ShadowMap = V.require("ShadowMap")
|
||||||
local VoxelGrid = V.require("VoxelGrid")
|
local VoxelGrid = V.require("VoxelGrid")
|
||||||
local WorldCurve = V.require("WorldCurve")
|
local WorldCurve = V.require("WorldCurve")
|
||||||
|
local Sky = V.require("Sky")
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
local GlassMask = V.require("GlassMask")
|
||||||
|
local PixelCanvas = V.require("PixelCanvas")
|
||||||
|
|
||||||
local Voxel3D = {}
|
local Voxel3D = {}
|
||||||
|
|
||||||
@@ -200,6 +204,13 @@ local SHADER = [[
|
|||||||
|
|
||||||
uniform vec3 ghostColor; // the flat silhouette colour
|
uniform vec3 ghostColor; // the flat silhouette colour
|
||||||
uniform float ghost; // 0 = shade normally, 1 = flatten to it
|
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 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
vec4 p = Texel(tex, tc);
|
vec4 p = Texel(tex, tc);
|
||||||
@@ -207,12 +218,44 @@ local SHADER = [[
|
|||||||
// blending keeps those texels out of the depth buffer, so a model never
|
// blending keeps those texels out of the depth buffer, so a model never
|
||||||
// carves a transparent hole out of whatever stands behind it
|
// carves a transparent hole out of whatever stands behind it
|
||||||
if (p.a < 0.5) discard;
|
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
|
#ifdef VOXEL_GRID
|
||||||
// darken what is there rather than painting a colour, so a seam across
|
// 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
|
// dark grass and one across a white roof each stay in their own palette
|
||||||
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
|
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
|
||||||
#endif
|
#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
|
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
|
||||||
// through `color` could only multiply the sprite's own pixels, which
|
// through `color` could only multiply the sprite's own pixels, which
|
||||||
// darkens each one by its own amount and keeps the character's internal
|
// darkens each one by its own amount and keeps the character's internal
|
||||||
@@ -241,8 +284,66 @@ local activeShader = nil -- the variant this pass bound
|
|||||||
-- resize, so the pair is stable for a session.
|
-- resize, so the pair is stable for a session.
|
||||||
local slots = {}
|
local slots = {}
|
||||||
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
|
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
|
||||||
|
local held = nil -- and the whole record for it
|
||||||
local active = false
|
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()
|
local IDENTITY = Mat4.identity()
|
||||||
|
|
||||||
-- Whether the driver admits to supporting derivatives. Only a hint --
|
-- Whether the driver admits to supporting derivatives. Only a hint --
|
||||||
@@ -321,7 +422,14 @@ end
|
|||||||
-- ---------------------------------------------------------------- camera --
|
-- ---------------------------------------------------------------- camera --
|
||||||
|
|
||||||
-- An explicit camera, replacing the orbit below for as long as it is set:
|
-- 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 }.
|
||||||
|
--
|
||||||
|
-- A caller with matrices of its own -- the VR eyes, whose view comes from
|
||||||
|
-- a tracked pose and whose projection is an off-centre frustum no
|
||||||
|
-- eye/focus/fov triple can express -- sets `view` and `proj` instead, and
|
||||||
|
-- the eye/focus fields stay for everything that reasons about the camera
|
||||||
|
-- rather than projecting with it (setLook, the sky, the water's lean).
|
||||||
--
|
--
|
||||||
-- The orbit is the free-roam camera and it is described entirely by ONE
|
-- 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
|
-- number, the pitch, because that is all a camera following the player over
|
||||||
@@ -337,6 +445,48 @@ end
|
|||||||
-- way either way.
|
-- way either way.
|
||||||
Voxel3D.camera = nil
|
Voxel3D.camera = nil
|
||||||
|
|
||||||
|
-- This frame's camera RAY FAN, set by viewProjection alongside vp: the
|
||||||
|
-- world direction a canvas point looks along (see Sky.paint's `ray`).
|
||||||
|
-- Present for every free-pitch camera -- the VR eyes bring theirs
|
||||||
|
-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil
|
||||||
|
-- for the orbit, whose frame-hung sky is the classic look.
|
||||||
|
Voxel3D.skyRayLive = 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
|
-- View and projection for a `vw` x `vh` world-pixel view centred on
|
||||||
-- (cx, cy) in world pixels. Returns the combined matrix.
|
-- (cx, cy) in world pixels. Returns the combined matrix.
|
||||||
function Voxel3D.viewProjection(cx, cy, vw, vh)
|
function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||||
@@ -344,31 +494,87 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
|||||||
if cam then
|
if cam then
|
||||||
local eye, focus = cam.eye, cam.focus
|
local eye, focus = cam.eye, cam.focus
|
||||||
Voxel3D.eye = eye
|
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)
|
||||||
|
-- a camera that brought its own matrices (a VR eye) projects with
|
||||||
|
-- them; only the clip-space Y flip is added, for the same canvas
|
||||||
|
-- reason as every other branch here
|
||||||
|
if cam.view and cam.proj then
|
||||||
|
Voxel3D.fovY = cam.fov
|
||||||
|
-- the VR eyes bring their fan with them (VRRig.eyeCamera)
|
||||||
|
Voxel3D.skyRayLive = cam.skyRay
|
||||||
|
return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view)
|
||||||
|
end
|
||||||
local dx = eye[1] - focus[1]
|
local dx = eye[1] - focus[1]
|
||||||
local dy = eye[2] - focus[2]
|
local dy = eye[2] - focus[2]
|
||||||
local dz = eye[3] - focus[3]
|
local dz = eye[3] - focus[3]
|
||||||
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
|
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,
|
local proj = Mat4.perspective(cam.fov, vw / vh,
|
||||||
math.max(1, dist * 0.05), dist * 4 + 4096)
|
math.max(1, dist * 0.05), dist * 4 + 4096)
|
||||||
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
-- 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
|
-- bypass LOVE's transform_projection and canvas coordinates run Y down
|
||||||
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
||||||
-- world up, so the horizon stays level -- a placed camera that rolled
|
-- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`):
|
||||||
-- with its own pitch would tip the whole arena
|
-- a placed camera with a FREE PITCH -- the first-person rig, steered
|
||||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
|
-- by a mouse on the flat screen -- must not hang its gradient off the
|
||||||
|
-- frame, or looking up and down drags the bands with the view. Built
|
||||||
|
-- from the very basis the view below is: forward, the true right, the
|
||||||
|
-- true up, and the symmetric frustum's tangents.
|
||||||
|
local upv = cam.up or { 0, 1, 0 }
|
||||||
|
local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist
|
||||||
|
local crx = fy * upv[3] - fz * upv[2]
|
||||||
|
local cry = fz * upv[1] - fx * upv[3]
|
||||||
|
local crz = fx * upv[2] - fy * upv[1]
|
||||||
|
local crl = math.sqrt(crx * crx + cry * cry + crz * crz)
|
||||||
|
if crl > 1e-6 then
|
||||||
|
crx, cry, crz = crx / crl, cry / crl, crz / crl
|
||||||
|
local cux = cry * fz - crz * fy
|
||||||
|
local cuy = crz * fx - crx * fz
|
||||||
|
local cuz = crx * fy - cry * fx
|
||||||
|
local tanY = math.tan(cam.fov / 2)
|
||||||
|
local tanX = tanY * (vw / vh)
|
||||||
|
Voxel3D.skyRayLive = {
|
||||||
|
base = { fx - crx * tanX + cux * tanY,
|
||||||
|
fy - cry * tanX + cuy * tanY,
|
||||||
|
fz - crz * tanX + cuz * tanY },
|
||||||
|
du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX },
|
||||||
|
dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY },
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Voxel3D.skyRayLive = nil
|
||||||
|
end
|
||||||
|
-- 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
|
end
|
||||||
|
|
||||||
|
-- the orbit: a fixed pitch per rung, and the classic frame-hung sky --
|
||||||
|
-- no ray fan wanted
|
||||||
|
Voxel3D.skyRayLive = nil
|
||||||
|
|
||||||
local a = Voxel.angle
|
local a = Voxel.angle
|
||||||
local focal = Voxel.FOCAL
|
local focal = Voxel.FOCAL
|
||||||
local dist = focal * vh
|
local dist = focal * vh
|
||||||
-- the FOV that makes a straight-down camera at `dist` frame exactly `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
|
-- world pixels, which is the framing the flat view already has
|
||||||
local fov = 2 * math.atan(1 / (2 * focal))
|
local fov = 2 * math.atan(1 / (2 * focal))
|
||||||
|
Voxel3D.fovY = fov
|
||||||
|
|
||||||
local focus = { cx, 0, cy }
|
local focus = { cx, 0, cy }
|
||||||
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
|
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
|
||||||
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
|
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
|
||||||
Voxel3D.eye = eye
|
Voxel3D.eye = eye
|
||||||
|
Voxel3D.focus = focus
|
||||||
|
setLook(eye, focus)
|
||||||
-- perpendicular to the view direction in the YZ plane: north is screen-up
|
-- 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
|
-- 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.
|
-- parallel to the view direction, so there is no degenerate a = 0 case.
|
||||||
@@ -386,6 +592,213 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
|||||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
|
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
|
||||||
end
|
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 horizon as a LINE rather than a row, for a camera that can ROLL --
|
||||||
|
-- a VR eye. A head tipped sideways tips the true horizon across the
|
||||||
|
-- canvas, and a sky painted in flat rows then visibly hinges with the
|
||||||
|
-- head. So: project the flat forward direction (a point ON the vanishing
|
||||||
|
-- line) and the same direction nudged a hair of world-up (a point just
|
||||||
|
-- above it); the difference is the canvas direction "down toward the
|
||||||
|
-- ground", perpendicular to the horizon however the head is tipped.
|
||||||
|
--
|
||||||
|
-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from
|
||||||
|
-- sky toward ground, the horizon's signed distance along it -- a pixel at
|
||||||
|
-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and,
|
||||||
|
-- when `elev` (radians) is given, the distance the direction that far
|
||||||
|
-- ABOVE the horizon projects to. `top` is what pins the gradient's far
|
||||||
|
-- end to a real direction in the sky: extrapolating it linearly from a
|
||||||
|
-- pixels-per-radian estimate left the bands sliding as a pitch moved the
|
||||||
|
-- horizon through the frame, because a perspective's rows are tan-spaced,
|
||||||
|
-- not angle-spaced. nil `top` (the elevated direction is outside this
|
||||||
|
-- frustum's forward hemisphere) leaves the caller its estimate. nil
|
||||||
|
-- everything with no horizon in front of this camera.
|
||||||
|
function Voxel3D.horizonLine(w, h, elev)
|
||||||
|
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
|
||||||
|
if not (m and eye and focus and w 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
|
||||||
|
local function proj(vx, vy, vz)
|
||||||
|
local x = m[1] * vx + m[2] * vy + m[3] * vz
|
||||||
|
local y = m[5] * vx + m[6] * vy + m[7] * vz
|
||||||
|
local ww = m[13] * vx + m[14] * vy + m[15] * vz
|
||||||
|
if ww <= 1e-6 then return nil end
|
||||||
|
return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h
|
||||||
|
end
|
||||||
|
local qx, qy = proj(dx, 0, dz)
|
||||||
|
if not qx then return nil end
|
||||||
|
local rx, ry = proj(dx, 0.02, dz)
|
||||||
|
if not rx then return nil end
|
||||||
|
local ax, ay = qx - rx, qy - ry
|
||||||
|
local al = math.sqrt(ax * ax + ay * ay)
|
||||||
|
if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end
|
||||||
|
local top = nil
|
||||||
|
if elev then
|
||||||
|
local ce, se = math.cos(elev), math.sin(elev)
|
||||||
|
local tx, ty = proj(dx * ce, se, dz * ce)
|
||||||
|
if tx then top = tx * ax + ty * ay end
|
||||||
|
end
|
||||||
|
return ax, ay, qx * ax + qy * ay, top
|
||||||
|
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,
|
||||||
|
-- the body's WORLD direction, for the skybox path: a ray-fan caller
|
||||||
|
-- measures the twilight glow by the angle between a pixel's ray and
|
||||||
|
-- this, so the glow is pinned to the sky like the bands are (see
|
||||||
|
-- Sky.paint's glowDir)
|
||||||
|
dx = b.dx, dy = b.dy, dz = b.dz,
|
||||||
|
moon = b.moon,
|
||||||
|
glowAmt = amt,
|
||||||
|
glowColor = color,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the VR sky's world-anchored pieces
|
||||||
|
--
|
||||||
|
-- Both exist because a headset showed the shortcuts: a gradient painted
|
||||||
|
-- off the frame moved with the head that carried the frame, and a
|
||||||
|
-- screen-space disc re-snapped its cell grid with every head movement
|
||||||
|
-- and held its face square to the canvas instead of to the world. The
|
||||||
|
-- gradient's fix rides the camera record itself (skyRay -- see VRRig and
|
||||||
|
-- Sky's useRay path); the disc's is below.
|
||||||
|
|
||||||
|
-- The sun or moon as a QUAD IN THE WORLD: the baked cell art
|
||||||
|
-- (Sky.discImage) on a square spanned about the hour's direction, its
|
||||||
|
-- corners projected through this very eye -- so the disc is pinned to
|
||||||
|
-- the sky like the terrain is to the ground, stable under every head
|
||||||
|
-- motion, its face upright over the world. Runs inside beginScene's sky
|
||||||
|
-- window, before the depth mode is set, so the world draws over it.
|
||||||
|
local discMesh = nil
|
||||||
|
|
||||||
|
local function drawWorldDisc(w, h)
|
||||||
|
local b = DayNight.body()
|
||||||
|
if not (b and b.dy and b.dy > 0.005) then return end
|
||||||
|
local amt = DayNight.glow()
|
||||||
|
local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon))
|
||||||
|
if not img then return end
|
||||||
|
local m = Voxel3D.vp
|
||||||
|
if not m then return end
|
||||||
|
local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz)
|
||||||
|
if hl < 1e-6 then return end
|
||||||
|
-- right = horizontal, perpendicular to the direction; up completes it
|
||||||
|
local rx, rz = b.dz / hl, -b.dx / hl
|
||||||
|
local ux = -rz * b.dy
|
||||||
|
local uy = rz * b.dx - rx * b.dz
|
||||||
|
local uz = rx * b.dy
|
||||||
|
local ul = math.sqrt(ux * ux + uy * uy + uz * uz)
|
||||||
|
if ul < 1e-6 then return end
|
||||||
|
ux, uy, uz = ux / ul, uy / ul, uz / ul
|
||||||
|
if uy < 0 then ux, uy, uz = -ux, -uy, -uz end
|
||||||
|
-- apparent size is an ANGLE, the same fraction of the view the flat
|
||||||
|
-- screen's disc takes of its frame; the low sun looms exactly as there
|
||||||
|
local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1)
|
||||||
|
if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end
|
||||||
|
local k = math.tan(ang)
|
||||||
|
local verts = {}
|
||||||
|
local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 },
|
||||||
|
{ 1, 1, 1, 0 }, { -1, 1, 0, 0 } }
|
||||||
|
for i, c in ipairs(corners) do
|
||||||
|
local vx = b.dx + (rx * c[1] + ux * c[2]) * k
|
||||||
|
local vy = b.dy + (uy * c[2]) * k
|
||||||
|
local vz = b.dz + (rz * c[1] + uz * c[2]) * k
|
||||||
|
local x = m[1] * vx + m[2] * vy + m[3] * vz
|
||||||
|
local y = m[5] * vx + m[6] * vy + m[7] * vz
|
||||||
|
local ww = m[13] * vx + m[14] * vy + m[15] * vz
|
||||||
|
if ww <= 1e-6 then return end
|
||||||
|
verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h,
|
||||||
|
c[3], c[4] }
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
if not discMesh then
|
||||||
|
discMesh = love.graphics.newMesh(4, "fan", "stream")
|
||||||
|
end
|
||||||
|
discMesh:setVertices(verts)
|
||||||
|
discMesh:setTexture(img)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(discMesh)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- ----------------------------------------------------------------- scene --
|
-- ----------------------------------------------------------------- scene --
|
||||||
|
|
||||||
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
|
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
|
||||||
@@ -406,28 +819,74 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
end
|
end
|
||||||
if not sh then return false end
|
if not sh then return false end
|
||||||
local name = slot or "world"
|
local name = slot or "world"
|
||||||
local held = slots[name]
|
local slotHeld = slots[name]
|
||||||
if not (held and held.w == w and held.h == h) then
|
if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then
|
||||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
local ok, c = PixelCanvas.new(w, h)
|
||||||
if not ok then return false end
|
if not ok then return false end
|
||||||
c:setFilter("nearest", "nearest")
|
c:setFilter("nearest", "nearest")
|
||||||
if held and held.canvas and held.canvas.release then
|
if slotHeld then releaseSlot(slotHeld) end
|
||||||
pcall(held.canvas.release, held.canvas)
|
-- the depth canvas is sized with its colour, so a window resize
|
||||||
end
|
-- reallocates the pair together and they can never disagree
|
||||||
held = { canvas = c, w = w, h = h }
|
slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) }
|
||||||
slots[name] = held
|
slots[name] = slotHeld
|
||||||
end
|
end
|
||||||
|
held = slotHeld
|
||||||
canvas, canvasW, canvasH = held.canvas, w, h
|
canvas, canvasW, canvasH = held.canvas, w, h
|
||||||
-- a depth buffer is what makes occlusion real: walk behind a building and
|
-- a depth buffer is what makes occlusion real: walk behind a building and
|
||||||
-- the building wins, with no y-sorting anywhere
|
-- the building wins, with no y-sorting anywhere
|
||||||
local ok = pcall(love.graphics.setCanvas,
|
local ok = pcall(love.graphics.setCanvas, depthTarget())
|
||||||
{ canvas, depth = true })
|
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
|
if not ok then
|
||||||
pcall(love.graphics.setCanvas)
|
pcall(love.graphics.setCanvas)
|
||||||
return false
|
return false
|
||||||
end
|
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)
|
||||||
|
-- A FREE-PITCH camera's sky is ANCHORED IN SPACE, where the orbit's is
|
||||||
|
-- glued to the frame. One discriminator: skyRayLive, set by
|
||||||
|
-- viewProjection above for every camera whose pitch the player steers
|
||||||
|
-- -- the VR eyes and the flat first-person rig alike. With a fan, the
|
||||||
|
-- gradient is a SKYBOX (every pixel takes its band, and its GBC
|
||||||
|
-- checker, from its ray's true elevation -- no motion of the camera
|
||||||
|
-- moves a band, only the clock recolours them) and the sun or moon
|
||||||
|
-- hangs in the WORLD (drawWorldDisc). Without one -- the orbit, whose
|
||||||
|
-- pitch is the rung's -- the classic frame-hung painting stands.
|
||||||
|
local skyRay = Voxel3D.skyRayLive
|
||||||
|
local hy = Voxel3D.horizonY(h)
|
||||||
|
-- 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, hy) or nil
|
||||||
if sky then
|
if sky then
|
||||||
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
|
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.
|
||||||
|
if skyRay and sky.bands then
|
||||||
|
Sky.paint(w, h, sky, nil, Voxel3D.cell, Voxel3D.skyBody(w, h),
|
||||||
|
nil, nil, skyRay)
|
||||||
|
drawWorldDisc(w, h)
|
||||||
|
else
|
||||||
|
Sky.paint(w, h, sky, hy, Voxel3D.cell,
|
||||||
|
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
love.graphics.clear(0, 0, 0, 0, true, true)
|
love.graphics.clear(0, 0, 0, 0, true, true)
|
||||||
end
|
end
|
||||||
@@ -438,7 +897,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
love.graphics.setMeshCullMode("none")
|
love.graphics.setMeshCullMode("none")
|
||||||
love.graphics.setShader(sh)
|
love.graphics.setShader(sh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
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, "vp", "row", Voxel3D.vp)
|
||||||
pcall(sh.send, sh, "eye", Voxel3D.eye)
|
pcall(sh.send, sh, "eye", Voxel3D.eye)
|
||||||
-- the sun's frame, filled by ShadowMap just before this pass opened.
|
-- the sun's frame, filled by ShadowMap just before this pass opened.
|
||||||
@@ -454,7 +912,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
||||||
if grid then
|
if grid then
|
||||||
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
||||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
|
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
|
||||||
end
|
end
|
||||||
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
||||||
-- every frame rather than once, because a scene that opened mid-ghost --
|
-- every frame rather than once, because a scene that opened mid-ghost --
|
||||||
@@ -462,6 +920,22 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
-- start out flattening everything it drew.
|
-- start out flattening everything it drew.
|
||||||
pcall(sh.send, sh, "ghost", 0)
|
pcall(sh.send, sh, "ghost", 0)
|
||||||
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
|
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
|
-- 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 fixed distance ahead of the player rather than sitting on the map.
|
||||||
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
|
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
|
||||||
@@ -537,6 +1011,167 @@ function Voxel3D.beginGhost()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Flatten whatever is drawn next to one solid colour, or nil to stop.
|
||||||
|
--
|
||||||
|
-- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted
|
||||||
|
-- depth test and half alpha -- this is for something drawn normally that
|
||||||
|
-- simply wants to come out one colour, which is what a hit flash on a sprite
|
||||||
|
-- is. beginScene resets the uniform every frame, so a pass that forgets to
|
||||||
|
-- clear it cannot leak into the next one.
|
||||||
|
-- `amount` is how far toward that colour, 0..1; omitted is all the way.
|
||||||
|
-- Anything short of 1 leaves the sprite's own shading showing through, which
|
||||||
|
-- is the difference between a hit flash and a white cut-out.
|
||||||
|
function Voxel3D.flatten(color, amount)
|
||||||
|
if not (active and activeShader) then return end
|
||||||
|
local sh = activeShader
|
||||||
|
if color then
|
||||||
|
pcall(sh.send, sh, "ghostColor", color)
|
||||||
|
pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1)))
|
||||||
|
else
|
||||||
|
pcall(sh.send, sh, "ghost", 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------- the water pass --
|
||||||
|
--
|
||||||
|
-- A reflective surface has to READ the frame it is being drawn into: the
|
||||||
|
-- colour of what is standing around it and the depth that says where. Both
|
||||||
|
-- are attachments of the target this pass is bound to, and a texture cannot
|
||||||
|
-- be sampled while it is one -- so for the length of the water draw the
|
||||||
|
-- frame is taken apart:
|
||||||
|
--
|
||||||
|
-- the COLOUR is copied to a mirror canvas, which is a texture like any
|
||||||
|
-- other and is what the reflection samples.
|
||||||
|
--
|
||||||
|
-- the DEPTH is simply detached. The water shader does the test itself
|
||||||
|
-- against the texture (see Water), which is the same comparison the
|
||||||
|
-- hardware would have made -- what it gives up is depth WRITES, and water
|
||||||
|
-- is flat, never overlaps itself, and has nothing drawn under it later.
|
||||||
|
--
|
||||||
|
-- `paint`, when given, is called with the MIRROR bound and the scene shader
|
||||||
|
-- set, to add things that must be REFLECTED without being composited yet.
|
||||||
|
--
|
||||||
|
-- The characters are the whole reason it exists. Gen 1 draws people over
|
||||||
|
-- the world and water is world, so the cast has to composite AFTER the
|
||||||
|
-- water -- but a reflection can only contain what was drawn BEFORE it, and
|
||||||
|
-- a lake with everyone standing beside it and nobody in it reads as glass.
|
||||||
|
-- Painting them into the mirror alone settles both: they are in the picture
|
||||||
|
-- the water reflects and not yet in the picture the water is drawn into.
|
||||||
|
--
|
||||||
|
-- They go down depth-TESTED and depth-WRITE-FREE. Tested, so a figure behind
|
||||||
|
-- a building is behind it in the reflection too; write-free because the very
|
||||||
|
-- next thing to read that buffer is the water's own depth test, and a cast
|
||||||
|
-- that had written to it would punch itself out of the water it is standing
|
||||||
|
-- beside.
|
||||||
|
--
|
||||||
|
-- Returns the two textures, or nil when there is nothing to hand over: no
|
||||||
|
-- readable depth canvas on this driver, or no pass open. A caller that gets
|
||||||
|
-- nil draws its water like ordinary terrain, which is what this mode always
|
||||||
|
-- did.
|
||||||
|
--
|
||||||
|
-- MUST be paired with endWater, which puts the frame back together.
|
||||||
|
function Voxel3D.beginWater(paint)
|
||||||
|
if not (active and canvas and held and held.depth) then return nil end
|
||||||
|
if not held.mirror then
|
||||||
|
local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
|
||||||
|
if not (ok and c) then return nil end
|
||||||
|
pcall(c.setFilter, c, "nearest", "nearest")
|
||||||
|
pcall(c.setWrap, c, "clamp", "clamp")
|
||||||
|
held.mirror = c
|
||||||
|
end
|
||||||
|
love.graphics.setShader()
|
||||||
|
-- the frame's own depth rides along, so the paint below can test against
|
||||||
|
-- it; the copy underneath switches the test off rather than detaching it
|
||||||
|
local ok = pcall(love.graphics.setCanvas,
|
||||||
|
{ held.mirror, depthstencil = held.depth })
|
||||||
|
if not ok then
|
||||||
|
pcall(love.graphics.setCanvas, depthTarget())
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
love.graphics.setDepthMode("always", false)
|
||||||
|
-- COLOUR only. The last two arguments are what keep the depth buffer the
|
||||||
|
-- frame's rather than this canvas's: cleared here, the water's own depth
|
||||||
|
-- test a few lines later would find nothing in front of anything and every
|
||||||
|
-- lake would draw straight through the buildings standing in it.
|
||||||
|
love.graphics.clear(0, 0, 0, 0, false, false)
|
||||||
|
-- premultiplied over a cleared target is a straight copy: every channel
|
||||||
|
-- lands exactly as it stood, including the alpha, so the mirror is the
|
||||||
|
-- frame rather than the frame composited against something
|
||||||
|
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(canvas)
|
||||||
|
love.graphics.setBlendMode("alpha")
|
||||||
|
if paint and activeShader then
|
||||||
|
love.graphics.setDepthMode("lequal", false)
|
||||||
|
love.graphics.setShader(activeShader)
|
||||||
|
pcall(paint)
|
||||||
|
love.graphics.setShader()
|
||||||
|
end
|
||||||
|
love.graphics.setDepthMode()
|
||||||
|
-- and back to the scene canvas WITHOUT its depth: that texture is about
|
||||||
|
-- to be read
|
||||||
|
if not pcall(love.graphics.setCanvas, canvas) then
|
||||||
|
pcall(love.graphics.setCanvas, depthTarget())
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return held.mirror, held.depth
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Put the frame back: depth reattached, depth test and the scene shader as
|
||||||
|
-- the pass had them. Safe to call after a beginWater that returned nil.
|
||||||
|
function Voxel3D.endWater()
|
||||||
|
if not active then return end
|
||||||
|
pcall(love.graphics.setCanvas, depthTarget())
|
||||||
|
pcall(love.graphics.setDepthMode, "lequal", true)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
if activeShader then love.graphics.setShader(activeShader) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether a reflective water pass can run in this frame at all -- there is
|
||||||
|
-- a depth texture to read. Callers use it to choose between the water
|
||||||
|
-- shader and an ordinary terrain draw before they start moving canvases.
|
||||||
|
function Voxel3D.depthReadable()
|
||||||
|
return (active and held and held.depth) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether what is drawn next carries the voxel wireframe. false for the
|
||||||
|
-- length of a draw, true to put it back.
|
||||||
|
--
|
||||||
|
-- The wireframe reads a mesh's OWN model space and darkens its integer
|
||||||
|
-- planes (see VoxelGrid), which is only a wireframe because every mesh in
|
||||||
|
-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a
|
||||||
|
-- character card in the sprite's own pixels. A mesh whose model space does
|
||||||
|
-- not mean that gets no wireframe out of the same shader -- it gets
|
||||||
|
-- whichever of its integer planes happen to fall inside it, which is a
|
||||||
|
-- stray line rather than a seam.
|
||||||
|
--
|
||||||
|
-- So this is not a style switch. It is how a mesh that is not on the voxel
|
||||||
|
-- grid says so, and the alternative -- rescaling such a mesh until its
|
||||||
|
-- units happen to be voxels -- would change what it IS to satisfy a
|
||||||
|
-- shading pass.
|
||||||
|
--
|
||||||
|
-- Sent rather than branched because the plain scene shader has no such
|
||||||
|
-- uniform, and the send simply does not take there -- which is right: with
|
||||||
|
-- no wireframe compiled in there is nothing to suppress.
|
||||||
|
function Voxel3D.seams(on)
|
||||||
|
if not (active and activeShader) then return end
|
||||||
|
pcall(activeShader.send, activeShader, "gridDark",
|
||||||
|
on and VoxelGrid.DARK or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether what is drawn next may consult the glass mask. false for the
|
||||||
|
-- length of a sprite-sheet pass, true to put it back.
|
||||||
|
--
|
||||||
|
-- Same shape as seams(), for the same reason: the mask means "this ATLAS
|
||||||
|
-- texel is window glass", so it is only an answer for meshes textured from
|
||||||
|
-- the tileset atlas. A sprite sheet's coordinates land wherever they land
|
||||||
|
-- on it, and at night that painted lamplight stripes down whoever was
|
||||||
|
-- standing in the wrong part of their own sheet.
|
||||||
|
function Voxel3D.glass(on)
|
||||||
|
if not (active and activeShader) then return end
|
||||||
|
pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0)
|
||||||
|
end
|
||||||
|
|
||||||
function Voxel3D.endGhost()
|
function Voxel3D.endGhost()
|
||||||
if not active then return end
|
if not active then return end
|
||||||
pcall(love.graphics.setDepthMode, "lequal", true)
|
pcall(love.graphics.setDepthMode, "lequal", true)
|
||||||
@@ -710,16 +1345,30 @@ function Voxel3D.canvas()
|
|||||||
return canvas
|
return canvas
|
||||||
end
|
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).
|
-- Drop the GPU objects (window resize, hot reload).
|
||||||
function Voxel3D.invalidate()
|
function Voxel3D.invalidate()
|
||||||
for name, held in pairs(slots) do
|
for name, slotHeld in pairs(slots) do
|
||||||
if held.canvas and held.canvas.release then
|
releaseSlot(slotHeld)
|
||||||
pcall(held.canvas.release, held.canvas)
|
|
||||||
end
|
|
||||||
slots[name] = nil
|
slots[name] = nil
|
||||||
end
|
end
|
||||||
canvas, canvasW, canvasH = nil, 0, 0
|
canvas, canvasW, canvasH = nil, 0, 0
|
||||||
|
held = nil
|
||||||
|
-- the VR sky's disc mesh belongs to this context like the canvases do
|
||||||
|
if discMesh and discMesh.release then pcall(discMesh.release, discMesh) end
|
||||||
|
discMesh = nil
|
||||||
ShadowMap.invalidate()
|
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
|
end
|
||||||
|
|
||||||
return Voxel3D
|
return Voxel3D
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
|
|||||||
-- 1.0 here is the one-pixel wireframe.
|
-- 1.0 here is the one-pixel wireframe.
|
||||||
VoxelGrid.WIDTH = 1.0
|
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)
|
-- where it persists and the rows that cycle it (see ModSetting)
|
||||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||||
{ false, true }, { "OFF", "ON" })
|
{ false, true }, { "OFF", "ON" })
|
||||||
|
|||||||
+669
-60
@@ -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
|
-- 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
|
-- the terrain mesh needs no transform at all and a connected map just
|
||||||
@@ -20,6 +20,13 @@ local SpriteBillboards = V.require("SpriteBillboards")
|
|||||||
local TileShape = V.require("TileShape")
|
local TileShape = V.require("TileShape")
|
||||||
local TerrainAtlas = V.require("TerrainAtlas")
|
local TerrainAtlas = V.require("TerrainAtlas")
|
||||||
local Voxel = V.require("VoxelState")
|
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 BattleBillboard = V.require("BattleBillboard")
|
||||||
|
local Pokedex = V.require("Pokedex")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
|
|
||||||
@@ -49,12 +56,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
|
|||||||
|
|
||||||
-- ------------------------------------------------------------------ sky --
|
-- ------------------------------------------------------------------ sky --
|
||||||
--
|
--
|
||||||
-- At the top rung the camera is pitched far enough over that the horizon
|
-- The void behind the diorama is SKY, at every rung -- so the world reads as
|
||||||
-- comes into frame and a good part of the picture is void -- so the void
|
-- standing under something rather than floating on a black plate.
|
||||||
-- becomes the sky, and the diorama reads as standing under something
|
--
|
||||||
-- rather than floating on a black plate. Below that rung the camera looks
|
-- What is up there differs by rung, and the sky follows it rather than being
|
||||||
-- down steeply enough that the horizon is off-screen, and painting the
|
-- retuned for each. At 75 degrees the camera is pitched far enough over that
|
||||||
-- void only tints the gaps between meshes, so it stays transparent.
|
-- 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
|
-- 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
|
-- ceiling, and the void past its walls is the outside of a box, not open
|
||||||
@@ -67,42 +78,78 @@ VoxelScene._modeColors = modeColors -- named for the suite
|
|||||||
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
|
-- 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
|
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
|
||||||
-- mismatch the terrain bake had.
|
-- 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 },
|
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
|
||||||
{ 64, 120, 192 }, { 16, 40, 80 } }
|
{ 64, 120, 192 }, { 16, 40, 80 } }
|
||||||
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
|
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
|
-- the ramp as the display mode has it, which is the only form anything here
|
||||||
-- camera tween instead of popping in on the keypress
|
-- 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 function skyStrength(angleRad)
|
||||||
local deg = math.deg(angleRad or 0)
|
local deg = math.deg(angleRad or 0)
|
||||||
local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below
|
if deg <= 0 then return 0 end
|
||||||
local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung
|
local t = deg / SKY_FADE_DEG
|
||||||
if to <= from then return deg >= to and 1 or 0 end
|
return t < 1 and t or 1
|
||||||
local t = (deg - from) / (to - from)
|
|
||||||
if t < 0 then return 0 end
|
|
||||||
if t > 1 then return 1 end
|
|
||||||
return t
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One shade off the sky ramp, transformed by the display mode, as an
|
-- 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
|
-- {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).
|
-- proper; 4 is its darkest, which is what an indoor void wants).
|
||||||
function VoxelScene.skyShade(shade, alpha)
|
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]
|
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 }
|
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The sky `map` stands under at strength `t`, or nil where there is no sky
|
-- 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.
|
-- 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)
|
function VoxelScene.skyColor(map, t)
|
||||||
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
|
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
|
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
|
end
|
||||||
|
|
||||||
|
-- The free-roam sky: the flat one above, dressed with the banded gradient
|
||||||
|
-- (lib/Sky.lua).
|
||||||
|
--
|
||||||
|
-- Only here, and deliberately. This is the sky the walking camera stands under,
|
||||||
|
-- where the horizon is a quarter of the way down the frame at the top rung and
|
||||||
|
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
|
||||||
|
-- placed camera whose horizon sits above the frame entirely, so it keeps the
|
||||||
|
-- flat fill it has always had -- there is no gradient to see from down there,
|
||||||
|
-- and the arena's look is not this rung's to change.
|
||||||
local function skyFor(map)
|
local 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
|
end
|
||||||
|
|
||||||
VoxelScene._skyFor = skyFor -- named for the suite
|
VoxelScene._skyFor = skyFor -- named for the suite
|
||||||
@@ -170,6 +217,22 @@ local function frameFor(def, facing, phase, flip)
|
|||||||
return frame, mirror
|
return frame, mirror
|
||||||
end
|
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
|
-- 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
|
-- a decal: its current sprite frame as a single quad, flattened onto the
|
||||||
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
|
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
|
||||||
@@ -192,17 +255,83 @@ end
|
|||||||
-- Shared by the solid draw and the silhouette below, so the two can never
|
-- Shared by the solid draw and the silhouette below, so the two can never
|
||||||
-- drift apart -- a silhouette standing anywhere but exactly behind the
|
-- drift apart -- a silhouette standing anywhere but exactly behind the
|
||||||
-- figure would read as a second character.
|
-- 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.
|
||||||
|
-- The pitch the sprite cards lean back by -- normally the rung's own
|
||||||
|
-- camera angle, overridable in radians. VR sets the override to the top
|
||||||
|
-- rung's 75 degrees for every diorama and battle frame: a table watched
|
||||||
|
-- from a freely moving head has no one camera pitch for the cards to
|
||||||
|
-- match, and the near-upright top-rung lean is the pose that reads as
|
||||||
|
-- "standing" from anywhere around it. nil (the default, and the flat
|
||||||
|
-- screen always) leans with the rung as ever.
|
||||||
|
VoxelScene.spriteLean = nil
|
||||||
|
|
||||||
|
local function leanAngle()
|
||||||
|
return VoxelScene.spriteLean or V.require("VoxelState").angle
|
||||||
|
end
|
||||||
|
|
||||||
local function billboardMatrix(px, py, y, mirror)
|
local function billboardMatrix(px, py, y, mirror)
|
||||||
local Voxel = V.require("VoxelState")
|
local b = FirstPerson.cardBlend()
|
||||||
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
|
local m = Mat4.translate(px + 8, y, py + 8)
|
||||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
if b > 0 then
|
||||||
|
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||||
|
end
|
||||||
|
m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
||||||
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
||||||
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function billboardPull()
|
local function billboardPull()
|
||||||
local Voxel = V.require("VoxelState")
|
return VoxelScene.pull(math.max(leanAngle(), 0.05))
|
||||||
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 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((leanAngle() - 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
|
end
|
||||||
|
|
||||||
-- Draw one posed entity. Returns true if 3D geometry carried it, false
|
-- Draw one posed entity. Returns true if 3D geometry carried it, false
|
||||||
@@ -235,12 +364,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
|
|||||||
-- drift): lets the leaned-back head win against the wall it leans
|
-- drift): lets the leaned-back head win against the wall it leans
|
||||||
-- OVER while a character genuinely BEHIND a building is dozens of
|
-- OVER while a character genuinely BEHIND a building is dozens of
|
||||||
-- pixels deeper and still loses, so real occlusion works.
|
-- pixels deeper and still loses, so real occlusion works.
|
||||||
-- the same card UNLEANED is what the sun saw (castShadows draws
|
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
|
||||||
-- exactly this mesh), so that is where each vertex asks whether the
|
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
|
||||||
-- light reached it -- see Voxel3D.draw
|
-- 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),
|
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
|
||||||
billboardPull(),
|
billboardPull(),
|
||||||
Voxel3D.casterMatrix(px, py, y, mirror))
|
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -258,7 +388,7 @@ VoxelScene.drawEntity = drawEntity
|
|||||||
-- mesh for it.
|
-- mesh for it.
|
||||||
local function drawGhost(p)
|
local function drawGhost(p)
|
||||||
local def = p.sprite.def
|
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)
|
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||||
if not mesh then return end
|
if not mesh then return end
|
||||||
local tex = p.sprite:resolveImage()
|
local tex = p.sprite:resolveImage()
|
||||||
@@ -331,17 +461,25 @@ function VoxelScene.prefetch(state)
|
|||||||
-- crossing demotes the map just left, and it must not vanish from
|
-- crossing demotes the map just left, and it must not vanish from
|
||||||
-- behind the player while its body variant builds; its ring is
|
-- 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.
|
-- 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
|
if not terrain then
|
||||||
terrain = ChunkMesher.peek(state.map, true)
|
terrain, water = ChunkMesher.pair(state.map, true)
|
||||||
end
|
end
|
||||||
local nbMesh = {}
|
local nbMesh, nbWater = {}, {}
|
||||||
for i, nb in ipairs(state.neighbors or {}) do
|
for i, nb in ipairs(state.neighbors or {}) do
|
||||||
nbMesh[i] = ChunkMesher.request(nb.map, true)
|
ChunkMesher.request(nb.map, true)
|
||||||
or ChunkMesher.peek(nb.map, false)
|
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
|
end
|
||||||
Voxel.ready = terrain ~= nil
|
Voxel.ready = terrain ~= nil
|
||||||
return terrain, nbMesh
|
return terrain, nbMesh, water, nbWater
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Capture every entity's pose for this frame. pose() advances the hop /
|
-- Capture every entity's pose for this frame. pose() advances the hop /
|
||||||
@@ -379,12 +517,219 @@ local function posesOf(state, spriteColors)
|
|||||||
gh = groundAt(state.map, e.cellX, e.cellY),
|
gh = groundAt(state.map, e.cellX, e.cellY),
|
||||||
lift = e.py - vy, colors = colors,
|
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
|
||||||
end
|
end
|
||||||
return posed, me
|
return posed, me
|
||||||
end
|
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
|
-- 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
|
-- 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
|
-- redrawing the whole world from the sun would buy nothing -- which is
|
||||||
@@ -406,6 +751,16 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
|||||||
-- standing perfectly still
|
-- standing perfectly still
|
||||||
put(vw); put(vh)
|
put(vw); put(vh)
|
||||||
put(math.floor((V.require("VoxelState").angle or 0) * 512))
|
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))
|
put(tostring(terrain))
|
||||||
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
||||||
for _, p in ipairs(posed) do
|
for _, p in ipairs(posed) do
|
||||||
@@ -429,9 +784,12 @@ end
|
|||||||
-- left out on purpose: thousands of tufts would cast a speckle no bigger
|
-- 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.
|
-- 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,
|
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||||
atlasFor)
|
atlasFor, water, nbWater, battleCards, battleToken)
|
||||||
if not ShadowMap.available() then return end
|
if not ShadowMap.available() then return end
|
||||||
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||||
|
-- a staged fight's pics move every frame the animation does, and the sun
|
||||||
|
-- has to follow them (VR frames only; see render)
|
||||||
|
if battleToken then sig = sig .. "|btl" .. tostring(battleToken) end
|
||||||
if not ShadowMap.stale(sig) then return end
|
if not ShadowMap.stale(sig) then return end
|
||||||
if not ShadowMap.begin(cx, cy, vw, vh) then return end
|
if not ShadowMap.begin(cx, cy, vw, vh) then return end
|
||||||
|
|
||||||
@@ -440,39 +798,105 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
|||||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||||
Mat4.translate(nb.ox, 0, nb.oy))
|
Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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
|
-- flower billboards live outside the terrain mesh (they draw after the
|
||||||
-- characters, pulled -- see render), but the sun still sees them: a
|
-- characters, pulled -- see render), but the sun still sees them: a
|
||||||
-- handful of cutouts per meadow, unlike the grass left out below
|
-- handful of cutouts per meadow, unlike the grass left out below.
|
||||||
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
|
-- 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
|
for _, nb in ipairs(state.neighbors or {}) do
|
||||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
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
|
end
|
||||||
for _, p in ipairs(posed) do
|
for _, p in ipairs(posed) do
|
||||||
local def = p.sprite.def
|
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)
|
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||||
if mesh then
|
if mesh then
|
||||||
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
||||||
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
|
ShadowMap.snug(
|
||||||
mirror))
|
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
|
||||||
|
mirror)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- a staged fight's mons (VR frames only): the same cards the eye pass
|
||||||
|
-- stands on the arena, snugged like every thin card, marked as the cast
|
||||||
|
-- so the water can decline them like everybody else's silhouette
|
||||||
|
for _, card in ipairs(battleCards or {}) do
|
||||||
|
ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model))
|
||||||
|
end
|
||||||
|
ShadowMap.sprites(false)
|
||||||
|
|
||||||
ShadowMap.finish(sig)
|
ShadowMap.finish(sig)
|
||||||
end
|
end
|
||||||
|
|
||||||
function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
-- Render the world. Without `eyes`, one frame into one canvas -- the flat
|
||||||
|
-- path every rung has always taken. With `eyes` -- a list of
|
||||||
|
-- { camera, w, h, slot, adopt } records, plus optional cx/cy for the
|
||||||
|
-- scene centre -- the same frame is drawn once per entry and the list of
|
||||||
|
-- canvases comes back: the VR path, two eyes over one shared shadow map,
|
||||||
|
-- pose capture and glint step.
|
||||||
|
function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
||||||
-- With nothing cached at all (the first frame of a fresh toggle),
|
-- With nothing cached at all (the first frame of a fresh toggle),
|
||||||
-- return nil: the engine keeps the 2D path for the frame and
|
-- return nil: the engine keeps the 2D path for the frame and
|
||||||
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
||||||
-- invisibly instead of freezing or tilting an empty stage.
|
-- 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
|
if not terrain then return nil end
|
||||||
|
|
||||||
local cam = state.camera
|
local cam = state.camera
|
||||||
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
|
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)
|
local function atlasFor(map)
|
||||||
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
|
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
|
||||||
end
|
end
|
||||||
@@ -485,12 +909,53 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local posed, me = posesOf(state, spriteColors)
|
local posed, me = posesOf(state, spriteColors)
|
||||||
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
|
|
||||||
|
|
||||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
-- The first-person rig, built (or blended) for this frame and handed to
|
||||||
return nil
|
-- 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.
|
||||||
|
--
|
||||||
|
-- A VR frame skips all of it: the caller brought its own cameras, and
|
||||||
|
-- its own idea of the scene centre with them.
|
||||||
|
if not eyes then
|
||||||
|
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
|
||||||
|
if fpRig then cx, cy = fpCx, fpCy end
|
||||||
|
elseif eyes.cx then
|
||||||
|
cx, cy = eyes.cx, eyes.cy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A staged fight, seen by the VR eyes: the flat screen draws the battle
|
||||||
|
-- SCREEN while one is up (this pass never runs), but the headset keeps
|
||||||
|
-- looking at the world, so the world had better have the fight on it.
|
||||||
|
-- Fetched per frame for the sun, and again per EYE in drawScene, because
|
||||||
|
-- the cards yaw toward whichever eye is asking.
|
||||||
|
local battleCards, battleTex, battleToken = nil, nil, nil
|
||||||
|
if eyes then
|
||||||
|
local okB, cards, tex, token = pcall(function()
|
||||||
|
return V.require("OverworldBattle").worldCards()
|
||||||
|
end)
|
||||||
|
if okB and cards then
|
||||||
|
battleCards, battleTex, battleToken = cards, tex, token
|
||||||
|
end
|
||||||
|
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, battleCards, battleToken)
|
||||||
|
|
||||||
|
-- Everything between beginScene and endScene, as one function: the flat
|
||||||
|
-- path runs it once, a VR frame runs it once PER EYE -- same posed
|
||||||
|
-- list, same shadow map, same glint, so the two eyes can never disagree
|
||||||
|
-- about anything but their viewpoint.
|
||||||
|
local function drawScene()
|
||||||
|
|
||||||
Voxel3D.draw(terrain, atlasFor(state.map), nil)
|
Voxel3D.draw(terrain, atlasFor(state.map), nil)
|
||||||
for i, nb in ipairs(state.neighbors or {}) do
|
for i, nb in ipairs(state.neighbors or {}) do
|
||||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||||
@@ -507,12 +972,45 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
if not Voxel3D.shadowsActive() then
|
if not Voxel3D.shadowsActive() then
|
||||||
Voxel3D.beginShadows()
|
Voxel3D.beginShadows()
|
||||||
for _, p in ipairs(posed) do
|
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)
|
p.lift)
|
||||||
end
|
end
|
||||||
Voxel3D.endShadows()
|
Voxel3D.endShadows()
|
||||||
end
|
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
|
-- 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,
|
-- 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
|
-- trees. Drawn after the solid pass it would meet the player's own card
|
||||||
@@ -520,19 +1018,67 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
-- wrote it, so the silhouette would paint over the player at all times.
|
-- 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
|
-- Every character then draws on top as usual, which leaves the silhouette
|
||||||
-- showing in exactly one situation: where the world hides them.
|
-- 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()
|
Voxel3D.beginGhost()
|
||||||
drawGhost(me)
|
drawGhost(me)
|
||||||
Voxel3D.endGhost()
|
Voxel3D.endGhost()
|
||||||
end
|
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
|
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||||
-- character genuinely behind a building is far deeper and loses the
|
-- character genuinely behind a building is far deeper and loses the
|
||||||
-- test, so buildings and trees really occlude.
|
-- test, so buildings and trees really occlude.
|
||||||
for _, p in ipairs(posed) do
|
drawCast(state, posed, atlasFor)
|
||||||
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
-- The staged fight's mons, standing on their arena cells in THIS eye's
|
||||||
p.colors, p.lift)
|
-- view (VR frames only; battleTex is nil otherwise). Rebuilt per eye
|
||||||
|
-- because the cards yaw toward the eye that is looking. No wireframe
|
||||||
|
-- and no glass on them for the reasons BattleBillboard and the battle
|
||||||
|
-- pass each argue: the cards are not on the voxel grid, and their
|
||||||
|
-- texcoords mean nothing to the tileset's pane mask. The hit flash
|
||||||
|
-- rides the same flatten the battle pass uses, held short of solid.
|
||||||
|
if battleTex then
|
||||||
|
local okB, cards = pcall(function()
|
||||||
|
return V.require("OverworldBattle").worldCards()
|
||||||
|
end)
|
||||||
|
if okB and cards then
|
||||||
|
local BattleScene = V.require("BattleScene")
|
||||||
|
Voxel3D.glass(false)
|
||||||
|
Voxel3D.seams(false)
|
||||||
|
if battleTex.flash then
|
||||||
|
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
|
||||||
|
end
|
||||||
|
for _, card in ipairs(cards) do
|
||||||
|
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
||||||
|
BattleBillboard.PULL)
|
||||||
|
end
|
||||||
|
if battleTex.flash then Voxel3D.flatten(nil) end
|
||||||
|
-- and the MOVE ANIMATIONS, standing on the same arena: the
|
||||||
|
-- engine's own effects layer on the plane through both cells
|
||||||
|
-- (BattleScene.fxCard), pulled a little harder than the mons so
|
||||||
|
-- a burst plays over the card it is bursting on
|
||||||
|
local okA, fxTex, fxModel = pcall(function()
|
||||||
|
return V.require("OverworldBattle").worldAnim()
|
||||||
|
end)
|
||||||
|
if okA and fxTex and fxModel then
|
||||||
|
Voxel3D.draw(BattleBillboard.mesh(), fxTex, fxModel,
|
||||||
|
BattleBillboard.PULL + 6)
|
||||||
|
end
|
||||||
|
Voxel3D.seams(true)
|
||||||
|
Voxel3D.glass(true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- tall grass last, pulled camera-ward exactly as far as the characters
|
-- tall grass last, pulled camera-ward exactly as far as the characters
|
||||||
-- were (same per-vertex shader bias, so grass never drifts either):
|
-- were (same per-vertex shader bias, so grass never drifts either):
|
||||||
@@ -540,8 +1086,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
-- is preserved, so the row still overdraws feet -- the 3D version of
|
-- is preserved, so the row still overdraws feet -- the 3D version of
|
||||||
-- the GB's grass-over-feet trick -- while grass keeps losing to the
|
-- the GB's grass-over-feet trick -- while grass keeps losing to the
|
||||||
-- buildings it genuinely stands behind (far deeper than the pull).
|
-- buildings it genuinely stands behind (far deeper than the pull).
|
||||||
local Voxel = V.require("VoxelState")
|
-- the same angle the cards leaned by (leanAngle honours VR's override),
|
||||||
local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
-- so the tuft rows keep exactly the characters' own depth handicap
|
||||||
|
local lean = math.max(leanAngle(), 0.05)
|
||||||
|
local pull = VoxelScene.pull(lean)
|
||||||
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
|
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
|
||||||
for _, nb in ipairs(state.neighbors or {}) do
|
for _, nb in ipairs(state.neighbors or {}) do
|
||||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||||
@@ -557,15 +1105,76 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
-- lands behind the card and the player obscures the patch they stand
|
-- lands behind the card and the player obscures the patch they stand
|
||||||
-- ON, while the nearest flower of the cell south (+20) stays in front
|
-- ON, while the nearest flower of the cell south (+20) stays in front
|
||||||
-- and keeps overdrawing their feet.
|
-- and keeps overdrawing their feet.
|
||||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
|
local fpull = math.max(0, pull - 8 * math.sin(lean))
|
||||||
|
-- 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,
|
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
|
||||||
fpull)
|
fpull, ShadowMap.snug(nil))
|
||||||
for _, nb in ipairs(state.neighbors or {}) do
|
for _, nb in ipairs(state.neighbors or {}) do
|
||||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
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
|
end
|
||||||
|
|
||||||
return Voxel3D.endScene()
|
-- The VR pokedex in the player's left hand, last of all: a prop over
|
||||||
|
-- the world drawn with real depth, so leaning it into a wall still
|
||||||
|
-- occludes honestly. Its frame only exists while a session is live and
|
||||||
|
-- the left hand is tracked (VR.lua sets it), so every flat frame skips
|
||||||
|
-- this in one field read. No wireframe and no glass, like the cast:
|
||||||
|
-- the device is a drawing riding the scene, not part of the terrain.
|
||||||
|
if Pokedex.frame then
|
||||||
|
Voxel3D.glass(false)
|
||||||
|
Voxel3D.seams(false)
|
||||||
|
Pokedex.draw()
|
||||||
|
Voxel3D.seams(true)
|
||||||
|
Voxel3D.glass(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- HORDE MODE's handgun, in the same slot and for the same reasons: a
|
||||||
|
-- prop over the world with real depth, no wireframe and no glass. In VR
|
||||||
|
-- it rides the tracked right hand (lib/VR placed it this frame); on the
|
||||||
|
-- flat screen it is carried by the camera, which is why it draws here
|
||||||
|
-- rather than in the overlay -- a view model that is 2D cannot be
|
||||||
|
-- occluded by the wall the player just backed into.
|
||||||
|
do
|
||||||
|
local HordeGun = V.require("HordeGun")
|
||||||
|
if HordeGun.visible() then
|
||||||
|
Voxel3D.glass(false)
|
||||||
|
Voxel3D.seams(false)
|
||||||
|
HordeGun.draw()
|
||||||
|
Voxel3D.seams(true)
|
||||||
|
Voxel3D.glass(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end -- drawScene
|
||||||
|
|
||||||
|
if not eyes then
|
||||||
|
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
drawScene()
|
||||||
|
return Voxel3D.endScene()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The VR frame: the same scene once per eye, each into its own named
|
||||||
|
-- canvas slot under its own placed camera. `adopt` hands the eye's
|
||||||
|
-- record to FirstPerson as the live rig, which is what turns the
|
||||||
|
-- billboards toward THIS eye in first person (cardBlend keys on rig
|
||||||
|
-- identity -- see FirstPerson) and leaves them leaning in the diorama,
|
||||||
|
-- where the blend is zero.
|
||||||
|
local out = {}
|
||||||
|
for i, eye in ipairs(eyes) do
|
||||||
|
Voxel3D.camera = eye.camera
|
||||||
|
if eye.adopt then FirstPerson.adoptVReye(eye.camera) end
|
||||||
|
if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh,
|
||||||
|
skyFor(state.map), eye.slot) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
drawScene()
|
||||||
|
out[i] = Voxel3D.endScene()
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
return VoxelScene
|
return VoxelScene
|
||||||
|
|||||||
+30
-7
@@ -29,11 +29,21 @@ local Voxel = {}
|
|||||||
-- it rather than assembling it from four rows. It sits directly after OFF
|
-- it rather than assembling it from four rows. It sits directly after OFF
|
||||||
-- because that is the order those two get used in.
|
-- because that is the order those two get used in.
|
||||||
--
|
--
|
||||||
-- Its ANGLE is 50 degrees, the same as the rung of that name. The duplicate
|
-- 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
|
-- 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.
|
-- like, and two rungs may look the same while meaning different things.
|
||||||
Voxel.ANGLES_DEG = { 0, 50, 15, 35, 50, 75 }
|
--
|
||||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
|
-- 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
|
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||||
|
|
||||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
-- 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
|
return (level or Voxel.level) == Voxel.FULL_LEVEL
|
||||||
end
|
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
|
-- ------- what the hotkey walks
|
||||||
--
|
--
|
||||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||||
@@ -51,14 +68,20 @@ end
|
|||||||
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
|
-- 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
|
-- 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.
|
-- 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`.
|
-- The rung a press moves to from `level`.
|
||||||
--
|
--
|
||||||
-- A level that is not on the key's path -- FULL, reached from the menu --
|
-- A level that is not on the key's path -- FULL, reached from the menu --
|
||||||
-- steps on from whichever rung shows the SAME camera it does. FULL is 50
|
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
|
||||||
-- degrees, so a press from it goes to 75 rather than back to 50, and the key
|
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
|
||||||
-- never appears to do nothing.
|
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
|
||||||
|
-- rung, so retuning FULL moves the key's answer with it.
|
||||||
function Voxel.nextHotkeyLevel(level)
|
function Voxel.nextHotkeyLevel(level)
|
||||||
level = level or Voxel.level
|
level = level or Voxel.level
|
||||||
local order = Voxel.HOTKEY_ORDER
|
local order = Voxel.HOTKEY_ORDER
|
||||||
|
|||||||
+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
|
-- the engine's TILT mode -- is engine plumbing driven by the records
|
||||||
-- below. This file declares; lib/ draws.
|
-- below. This file declares; lib/ draws.
|
||||||
--
|
--
|
||||||
-- Nothing here reaches collision, movement, triggers or scripts. Voxel
|
-- Voxel mode is presentational: it changes what the world LOOKS like and
|
||||||
-- mode is purely presentational: it changes what the world LOOKS like and
|
-- nothing about what it IS. ONE rung is the deliberate exception. 1ST --
|
||||||
-- nothing about what it IS.
|
-- 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 = ...
|
local mod = ...
|
||||||
|
|
||||||
@@ -78,6 +85,21 @@ local ChunkMesher = V.require("ChunkMesher")
|
|||||||
local VoxelGrid = V.require("VoxelGrid")
|
local VoxelGrid = V.require("VoxelGrid")
|
||||||
local WorldCurve = V.require("WorldCurve")
|
local WorldCurve = V.require("WorldCurve")
|
||||||
local OverworldBattle = V.require("OverworldBattle")
|
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")
|
||||||
|
local VR = V.require("VR")
|
||||||
|
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
|
||||||
|
-- every hook; the other four are the gun, the crowd, the readout and the
|
||||||
|
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
local HordeGun = V.require("HordeGun")
|
||||||
|
local HordeHud = V.require("HordeHud")
|
||||||
|
local HordeSfx = V.require("HordeSfx")
|
||||||
|
|
||||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||||
-- calls this, and it is defined further down with the settings it drives.
|
-- calls this, and it is defined further down with the settings it drives.
|
||||||
@@ -157,6 +179,16 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
-- would fight anyone who changed one deliberately.
|
-- would fight anyone who changed one deliberately.
|
||||||
applyFull(level)
|
applyFull(level)
|
||||||
Voxel.update(dt, 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
|
-- 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
|
-- 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.
|
-- screen the engine composites, which is not a stage the registry has.
|
||||||
@@ -166,6 +198,12 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
-- and the whole battle. Ahead of the active() gate below, because a 3D
|
-- and the whole battle. Ahead of the active() gate below, because a 3D
|
||||||
-- battle does not require the free-roam mode to be switched on.
|
-- battle does not require the free-roam mode to be switched on.
|
||||||
OverworldBattle.update(dt)
|
OverworldBattle.update(dt)
|
||||||
|
-- The horde, on the same always-running tick and for the same reason:
|
||||||
|
-- it owns no pass of the frame, it is a MODE over the overworld, and
|
||||||
|
-- it has to keep thinking while a warp's wipe covers the screen (the
|
||||||
|
-- crowd follows the player through the door) and under the GAME OVER
|
||||||
|
-- card, which is a pushed state that stops everything below it.
|
||||||
|
Horde.update(dt)
|
||||||
-- VOID FILL picks the block the border ring is made of, and in this
|
-- VOID FILL picks the block the border ring is made of, and in this
|
||||||
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
|
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
|
||||||
-- So the option has to reach the cache or nothing happens on screen
|
-- So the option has to reach the cache or nothing happens on screen
|
||||||
@@ -176,6 +214,13 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
-- them announces it. Ahead of the active() gate, so switching it
|
-- them announces it. Ahead of the active() gate, so switching it
|
||||||
-- while voxel mode is OFF still invalidates what is cached.
|
-- while voxel mode is OFF still invalidates what is cached.
|
||||||
voidFill.check()
|
voidFill.check()
|
||||||
|
-- The whole VR frame -- session lifecycle, xrWaitFrame's pacing, both
|
||||||
|
-- eye renders, the layer submit -- rides this hook, because it is the
|
||||||
|
-- one tick that runs through menus, dialogs and battles, which is
|
||||||
|
-- what a headset needs the world (or at least the UI panel) to do.
|
||||||
|
-- Ahead of the active() gate: with the mode off, the headset still
|
||||||
|
-- shows the flat screen on the floating panel.
|
||||||
|
VR.update(dt)
|
||||||
if not Voxel.active() then return end
|
if not Voxel.active() then return end
|
||||||
local Game = require("src.core.Game")
|
local Game = require("src.core.Game")
|
||||||
local ow = Game and Game.overworld
|
local ow = Game and Game.overworld
|
||||||
@@ -187,6 +232,19 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
end,
|
end,
|
||||||
|
|
||||||
drawWorld = function(ctx)
|
drawWorld = function(ctx)
|
||||||
|
-- the palette closure, stashed for the VR frame: it renders from the
|
||||||
|
-- update hook, where no ctx exists to carry one
|
||||||
|
VR.paletteFor = ctx.paletteFor
|
||||||
|
-- With a headset running, the window's world pass becomes the MIRROR
|
||||||
|
-- -- the left eye, fitted to the window -- rather than a third full
|
||||||
|
-- render of the scene. Everything else about the frame (the UI the
|
||||||
|
-- engine composites over this) is unchanged, which is exactly what
|
||||||
|
-- the headset's floating panel photographs.
|
||||||
|
if VR.active() then
|
||||||
|
local sw, sh = sceneSize(ctx)
|
||||||
|
local m = VR.mirror(sw, sh)
|
||||||
|
if m then return m end
|
||||||
|
end
|
||||||
-- Terrain and characters are geometry; the field FX stay ordinary 2D
|
-- Terrain and characters are geometry; the field FX stay ordinary 2D
|
||||||
-- draws composited on top, anchored through the same camera the 3D
|
-- draws composited on top, anchored through the same camera the 3D
|
||||||
-- pass used (ctx.drawFx below). The scene renders at the window's
|
-- pass used (ctx.drawFx below). The scene renders at the window's
|
||||||
@@ -194,21 +252,42 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
-- a magnified low-res image, while the FX closures keep drawing in
|
-- a magnified low-res image, while the FX closures keep drawing in
|
||||||
-- world-pixel units.
|
-- world-pixel units.
|
||||||
local sw, sh = sceneSize(ctx)
|
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)
|
ctx.vw, ctx.vh, ctx.paletteFor)
|
||||||
if not canvas then return nil end -- fall back to the 2D path
|
if not canvas then return nil end -- fall back to the 2D path
|
||||||
if Voxel3D.beginOverlay() then
|
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.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
||||||
ctx.scale)
|
ctx.scale * AntiAlias.factor())
|
||||||
|
-- the horde's readout rides the same overlay, over the FX: health,
|
||||||
|
-- ammunition, the crosshair and the banners, sized in the same
|
||||||
|
-- supersampled canvas pixels everything else here is drawn in. A
|
||||||
|
-- headset never reaches this line (drawWorld returns the mirror
|
||||||
|
-- above) -- lib/VR draws the same HUD onto each eye instead.
|
||||||
|
HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor())
|
||||||
Voxel3D.endOverlay()
|
Voxel3D.endOverlay()
|
||||||
end
|
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,
|
end,
|
||||||
|
|
||||||
invalidate = function()
|
invalidate = function()
|
||||||
Voxel3D.invalidate()
|
Voxel3D.invalidate()
|
||||||
OverworldBattle.invalidate()
|
OverworldBattle.invalidate()
|
||||||
|
AntiAlias.invalidate()
|
||||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||||
|
VR.invalidate() -- the mirror, and FBO ids of dead canvases
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -274,28 +353,138 @@ applyFull = function(level)
|
|||||||
-- the horizon flat. The curve bends the world away from a walking player,
|
-- the horizon flat. The curve bends the world away from a walking player,
|
||||||
-- which fights a fixed diorama framing
|
-- which fights a fixed diorama framing
|
||||||
WorldCurve.setting:setIndex(1, Game)
|
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
|
-- and the view fitted to the window
|
||||||
opts.zoom = 0
|
opts.zoom = 0
|
||||||
Zoom.applyOptions(opts)
|
Zoom.applyOptions(opts)
|
||||||
-- battles on the map too: FULL means the whole mode, and a fight is where
|
-- 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
|
-- half of it is spent. Set and then LET GO of -- unlike the rows above, both
|
||||||
-- menu while FULL is on, but a save that already had it off gets it on.
|
-- 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)
|
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
|
if Game.writeOptions then pcall(Game.writeOptions, Game) end
|
||||||
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 = {
|
local SETTINGS = {
|
||||||
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
||||||
{ WorldCurve.setting,
|
{ WorldCurve.setting,
|
||||||
"Bend the world down over the horizon, Animal Crossing style." },
|
"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.
|
||||||
|
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
|
||||||
|
-- battles (OverworldBattle.enabled answers true regardless of this row)
|
||||||
|
-- and forbids back sprites (backPinned answers false), so both rows
|
||||||
|
-- decide nothing there and a dead switch on the menu reads as broken.
|
||||||
{ OverworldBattle.setting,
|
{ OverworldBattle.setting,
|
||||||
"Fight on the map: the battle draws over the nearest clear ground, "
|
"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.",
|
||||||
|
when = function() return not VR.enabled() end, 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() and not VR.enabled() 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 },
|
||||||
|
-- `full` for the same reason as AA: not a knob on the look, a question
|
||||||
|
-- about the hardware on the desk.
|
||||||
|
{ VR.setting,
|
||||||
|
"PCVR through OpenXR (SteamVR, Oculus, WMR). The diorama becomes a "
|
||||||
|
.. "tabletop model your head moves around; the 1ST rung stands you "
|
||||||
|
.. "inside the world at life size, looking where the headset looks. "
|
||||||
|
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
|
||||||
|
.. "and the mod running from a real folder; without them the row stays "
|
||||||
|
.. "and the game stays flat, with the reason on the console.",
|
||||||
|
-- on Windows the row stays even when a runtime is missing (the console
|
||||||
|
-- says why); off Windows -- mobile above all -- there is no VR to have
|
||||||
|
-- and the row does not exist
|
||||||
|
when = function() return VR.supported() end, full = true },
|
||||||
|
-- Under the VR row and only while it is ON: a comfort setting for a
|
||||||
|
-- device that is not plugged in decides nothing, and this one is read
|
||||||
|
-- exclusively by the headset's right stick.
|
||||||
|
{ VR.smoothTurn,
|
||||||
|
"Turn smoothly with the right stick instead of snapping 45 degrees a "
|
||||||
|
.. "flick. OFF by default, and deliberately: a software turn moves the "
|
||||||
|
.. "world past a head that did not move, which is the most reliable way "
|
||||||
|
.. "to make somebody ill in a headset. Turn it on if you have your sea "
|
||||||
|
.. "legs and want the continuity.",
|
||||||
|
when = function() return VR.enabled() end, full = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local schema = {}
|
local schema = {}
|
||||||
for i, entry in ipairs(SETTINGS) do
|
for _, entry in ipairs(SETTINGS) do
|
||||||
schema[i] = entry[1]:schema(entry[2])
|
-- the VR rows are absent from the mod manager's page too where the
|
||||||
|
-- platform cannot do VR at all -- the OPTIONS menu's `when` gates are
|
||||||
|
-- situational (a row hidden for now), this one is existential
|
||||||
|
local vrOnly = entry[1] == VR.setting or entry[1] == VR.smoothTurn
|
||||||
|
if not vrOnly or VR.supported() then
|
||||||
|
schema[#schema + 1] = entry[1]:schema(entry[2])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
mod.options:define(schema)
|
mod.options:define(schema)
|
||||||
|
|
||||||
@@ -306,6 +495,7 @@ mod.options:define(schema)
|
|||||||
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
||||||
-- 7 V-CURVE cycle the horizon bend (new)
|
-- 7 V-CURVE cycle the horizon bend (new)
|
||||||
-- 8 3D-BTL toggle overworld battles (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
|
-- 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,
|
-- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM,
|
||||||
@@ -320,9 +510,12 @@ mod.options:define(schema)
|
|||||||
-- AND the engine's TILT on the same press.
|
-- AND the engine's TILT on the same press.
|
||||||
--
|
--
|
||||||
-- Consequences worth being explicit about: while this mod is enabled, TILT
|
-- 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
|
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
|
||||||
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
|
-- menu too, where both rows are taken away and both values held at zero (see
|
||||||
-- registry already forces it off whenever a world pipeline takes the pass.
|
-- 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
|
-- Everything the engine does around a pipeline hotkey has to happen here
|
||||||
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
||||||
@@ -335,14 +528,58 @@ local HOTKEYS = {
|
|||||||
["5"] = VoxelGrid.setting,
|
["5"] = VoxelGrid.setting,
|
||||||
["7"] = WorldCurve.setting,
|
["7"] = WorldCurve.setting,
|
||||||
["8"] = OverworldBattle.setting,
|
["8"] = OverworldBattle.setting,
|
||||||
|
["9"] = Water.setting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- One step of the VOXEL angle ladder: everything a "3" press does, named
|
||||||
|
-- so the pad's SELECT button (below) can make exactly the same step. The
|
||||||
|
-- gate is the registry's own; the tilt/GBC FX clearing is the engine work
|
||||||
|
-- the key has always delegated (see the wrap below for why).
|
||||||
|
local function cycleVoxel(game)
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
-- HORDE MODE holds the rung at 1ST for as long as it runs. Refused HERE
|
||||||
|
-- rather than at each caller because this one function IS every way a
|
||||||
|
-- player can step the ladder: the "3" key, the pad's SELECT, and the VR
|
||||||
|
-- left-stick click all come through it.
|
||||||
|
if Horde.viewLocked() then return false end
|
||||||
|
local top = game.stack and game.stack:top()
|
||||||
|
if not Pipelines.canToggle("voxel", top, game.overworld) then return false end
|
||||||
|
Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
||||||
|
Pipelines.syncOptions(game.save.options)
|
||||||
|
-- 3 is the key that used to turn TILT on and sits next to the one that
|
||||||
|
-- used to turn GBC FX on, and this mod has taken both away. A player who
|
||||||
|
-- left either running before enabling the mod would otherwise have no
|
||||||
|
-- way back to off, and both fight the diorama -- so the VOXEL step
|
||||||
|
-- clears them on EVERY press, not just the press that switches on.
|
||||||
|
game.save.options.tilt = 0
|
||||||
|
game.save.options.gbcfx = 0
|
||||||
|
require("src.render.GBCFX").setLevel(0)
|
||||||
|
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
|
||||||
|
game:writeOptions()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The VR stick click makes this same step (VR.stepView): the function is
|
||||||
|
-- a local of this file, so the handoff is explicit rather than a
|
||||||
|
-- reimplementation drifting out of date in lib/VR.lua.
|
||||||
|
VR.cycleVoxel = cycleVoxel
|
||||||
|
|
||||||
do
|
do
|
||||||
local Game = require("src.core.Game")
|
local Game = require("src.core.Game")
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local inner = Game.keypressed
|
local inner = Game.keypressed
|
||||||
|
|
||||||
function Game:keypressed(key)
|
function Game:keypressed(key)
|
||||||
|
-- HORDE MODE owns the keyboard's spare keys while it runs: R reloads,
|
||||||
|
-- and the mode keys are swallowed rather than left to change the rung
|
||||||
|
-- or the post-processing out from under a locked camera.
|
||||||
|
if Horde.active then
|
||||||
|
if key == "r" then
|
||||||
|
HordeGun.reload()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if HOTKEYS[key] then return end
|
||||||
|
end
|
||||||
local claim = HOTKEYS[key]
|
local claim = HOTKEYS[key]
|
||||||
local top = self.stack and self.stack:top()
|
local top = self.stack and self.stack:top()
|
||||||
-- A screen with its own key handler gets the key first, exactly as the
|
-- A screen with its own key handler gets the key first, exactly as the
|
||||||
@@ -353,46 +590,31 @@ do
|
|||||||
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
|
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
|
||||||
-- so the registry's plain "advance one and wrap" is not what it
|
-- so the registry's plain "advance one and wrap" is not what it
|
||||||
-- wants; 6 still is. The gate is the registry's own either way.
|
-- wants; 6 still is. The gate is the registry's own either way.
|
||||||
local stepped = false
|
-- The whole of 3's step lives in cycleVoxel, because the pad's
|
||||||
|
-- SELECT button makes the same step (see the handleInput wrap).
|
||||||
if key == "3" then
|
if key == "3" then
|
||||||
if Pipelines.canToggle("voxel", top, self.overworld) then
|
if cycleVoxel(self) then return end
|
||||||
Pipelines.setLevel("voxel",
|
elseif Pipelines.hotkey(key, top, self.overworld) then
|
||||||
Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
|
||||||
stepped = true
|
|
||||||
end
|
|
||||||
else
|
|
||||||
stepped = Pipelines.hotkey(key, top, self.overworld) and true
|
|
||||||
end
|
|
||||||
if stepped then
|
|
||||||
Pipelines.syncOptions(self.save.options)
|
Pipelines.syncOptions(self.save.options)
|
||||||
-- 3 is the key that used to turn TILT on and sits next to the one
|
|
||||||
-- that used to turn GBC FX on, and this mod has taken both away.
|
|
||||||
-- A player who left either running before enabling the mod would
|
|
||||||
-- otherwise have no way back to off, and both fight the diorama:
|
|
||||||
-- 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 it. So the
|
|
||||||
-- VOXEL key clears them on EVERY press, not just the press that
|
|
||||||
-- switches the mode on -- cycling back round to OFF leaves them
|
|
||||||
-- off too, which is the state the key is now the only route to.
|
|
||||||
if key == "3" then
|
|
||||||
self.save.options.tilt = 0
|
|
||||||
self.save.options.gbcfx = 0
|
|
||||||
require("src.render.GBCFX").setLevel(0)
|
|
||||||
end
|
|
||||||
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
elseif Pipelines.canToggle("voxel", top, self.overworld) then
|
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
|
-- borrowed from the registry rather than restated, so a press
|
||||||
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
|
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
|
||||||
-- it would be for the mode itself. Two of them parameterise that
|
-- it would be for the mode itself. Three of them parameterise that
|
||||||
-- pass; the third (3D-BTL) decides what a battle is drawn over, and
|
-- 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
|
-- 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
|
-- when the fight starts, so flipping it from inside one would be a
|
||||||
-- switch that appeared to do nothing.
|
-- switch that appeared to do nothing.
|
||||||
claim:cycle(self)
|
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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -425,10 +647,12 @@ local function insertGrouped(out, extra)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
-- FULL owns every one of those settings, so while it is selected they are
|
-- FULL owns the settings that describe the LOOK, so while it is selected those
|
||||||
-- taken off the menu rather than left to be changed under it -- including
|
-- 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
|
-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
|
||||||
-- longer decides anything is worse than no row.
|
-- 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)
|
local function dropRow(out, id)
|
||||||
for i = #out, 1, -1 do
|
for i = #out, 1, -1 do
|
||||||
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
|
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
|
||||||
@@ -436,17 +660,87 @@ local function dropRow(out, id)
|
|||||||
return out
|
return out
|
||||||
end
|
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
|
-- call next() first and decorate what comes back, so every other mod's
|
||||||
-- rows survive this one
|
-- rows survive this one
|
||||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||||
local out = next(game, rows)
|
local out = next(game, rows)
|
||||||
if type(out) ~= "table" then return out end
|
if type(out) ~= "table" then return out end
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
if Voxel.isFull(Pipelines.level("voxel")) then
|
-- ahead of every branch below, including FULL's early return: these two are
|
||||||
return dropRow(out, "pipeline:tiltshift")
|
-- 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
|
end
|
||||||
local extra = {}
|
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)
|
return insertGrouped(out, extra)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -457,6 +751,15 @@ mod.events:on("mod.options_changed", function(payload)
|
|||||||
for _, entry in ipairs(SETTINGS) do
|
for _, entry in ipairs(SETTINGS) do
|
||||||
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
|
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
|
||||||
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)
|
end)
|
||||||
|
|
||||||
-- ------- keeping the geometry in step with the world
|
-- ------- keeping the geometry in step with the world
|
||||||
@@ -539,7 +842,7 @@ mod.events:on("map.reloaded", function(payload)
|
|||||||
if mapId then ChunkMesher.invalidate(mapId) end
|
if mapId then ChunkMesher.invalidate(mapId) end
|
||||||
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
|
-- 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
|
-- that list every frame. So stepping the VOXEL row onto or off FULL changed
|
||||||
@@ -547,25 +850,44 @@ end)
|
|||||||
-- settings FULL owns stayed visible until the menu was closed and reopened,
|
-- 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.
|
-- 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
|
-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL,
|
||||||
-- returns the same list, and rebuilding on all of them would rerun every
|
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
|
||||||
-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
|
-- Every other rung returns the same list, and rebuilding on all of them would
|
||||||
-- than reset, so it stays on the VOXEL row it was just used on instead of
|
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
|
||||||
-- jumping to the top when the list below it shortens.
|
-- 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
|
do
|
||||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||||
if not OptionsMenu.dramaticShapeFullHook then
|
if not OptionsMenu.dramaticShapeFullHook then
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local inner = OptionsMenu.update
|
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)
|
function OptionsMenu:update(dt)
|
||||||
local before = Pipelines.level("voxel")
|
local before = Pipelines.level("voxel")
|
||||||
|
local hadBattles = OverworldBattle.enabled()
|
||||||
|
-- the VR row hides the two battle rows while it is on, so stepping
|
||||||
|
-- it changes the LIST exactly the way 3D-BTL does
|
||||||
|
local hadVR = VR.enabled()
|
||||||
|
local wasOn = idAt(self, self.index)
|
||||||
inner(self, dt)
|
inner(self, dt)
|
||||||
local after = Pipelines.level("voxel")
|
local after = Pipelines.level("voxel")
|
||||||
if after ~= before
|
local crossedFull = after ~= before
|
||||||
and (Voxel.isFull(before) or Voxel.isFull(after)) then
|
and (Voxel.isFull(before) or Voxel.isFull(after))
|
||||||
|
if crossedFull or OverworldBattle.enabled() ~= hadBattles
|
||||||
|
or VR.enabled() ~= hadVR then
|
||||||
local rebuilt = OptionsMenu.new(self.game)
|
local rebuilt = OptionsMenu.new(self.game)
|
||||||
self.rows = rebuilt.rows
|
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
|
local cancel = #self.rows + 1
|
||||||
if (self.index or 1) > cancel then self.index = cancel end
|
if (self.index or 1) > cancel then self.index = cancel end
|
||||||
end
|
end
|
||||||
@@ -583,6 +905,155 @@ end
|
|||||||
-- so this file keeps naming every engine seam the mod touches.
|
-- so this file keeps naming every engine seam the mod touches.
|
||||||
OverworldBattle.install()
|
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()
|
||||||
|
|
||||||
|
-- ------- horde mode
|
||||||
|
--
|
||||||
|
-- The sounds are registered before anything can ask for one; the hooks go
|
||||||
|
-- in AFTER FreeMove (and, below, after the SELECT wrap) so the mode's own
|
||||||
|
-- handleInput reasoning sits outside both of theirs, for the same reason
|
||||||
|
-- the SELECT hook does.
|
||||||
|
--
|
||||||
|
-- The GAME OVER card is a screens-registry record rather than a state
|
||||||
|
-- this file pushes directly: that is the engine's own seam for a mod
|
||||||
|
-- owning a screen, and it means the card is reachable by id from a driver
|
||||||
|
-- or a test without going through a death.
|
||||||
|
HordeSfx.register(mod)
|
||||||
|
|
||||||
|
mod.content.screens:register("HordeGameOver", {
|
||||||
|
new = function(game) return V.require("HordeGameOver").new(game) end,
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.content.screens:register("HordeExitPrompt", {
|
||||||
|
new = function(game) return V.require("HordeExitPrompt").new(game) end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- The gamepad's triggers, which nothing else in the engine or this mod
|
||||||
|
-- claims: the RIGHT one fires and the LEFT one aims. Read as axes because
|
||||||
|
-- that is what SDL calls them; the 0.5 crossing is the press. Installed
|
||||||
|
-- beside the other input wraps, and inert with the mode off.
|
||||||
|
do
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
if not Game.dramaticShapeHordeTriggers then
|
||||||
|
local inner = Game.gamepadaxis
|
||||||
|
local rightDown = false
|
||||||
|
function Game:gamepadaxis(joystick, axis, value)
|
||||||
|
if Horde.playing() then
|
||||||
|
if axis == "triggerright" then
|
||||||
|
local down = (value or 0) > 0.5
|
||||||
|
if down and not rightDown then HordeGun.fire() end
|
||||||
|
rightDown = down
|
||||||
|
elseif axis == "triggerleft" then
|
||||||
|
HordeGun.setAds((value or 0) > 0.35)
|
||||||
|
end
|
||||||
|
elseif rightDown then
|
||||||
|
rightDown = false
|
||||||
|
end
|
||||||
|
return inner(self, joystick, axis, value)
|
||||||
|
end
|
||||||
|
-- and X reloads, the only pad button the overworld does not already use
|
||||||
|
local innerBtn = Game.gamepadpressed
|
||||||
|
function Game:gamepadpressed(joystick, button)
|
||||||
|
if Horde.playing() and button == "x" then
|
||||||
|
HordeGun.reload()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return innerBtn(self, joystick, button)
|
||||||
|
end
|
||||||
|
Game.dramaticShapeHordeTriggers = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- SELECT walks the angle ladder
|
||||||
|
--
|
||||||
|
-- The same step the "3" key makes, on the pad's own button: a phone (and
|
||||||
|
-- a controller) has no number row, and SELECT has no overworld job in
|
||||||
|
-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam
|
||||||
|
-- is OverworldState:handleInput, the same choke point the free walk
|
||||||
|
-- replaced: every gate above it -- menus, dialogs, scripted moves,
|
||||||
|
-- transitions -- already decided the overworld owns the buttons, so a
|
||||||
|
-- SELECT here is free-roam by construction, exactly like the key. When
|
||||||
|
-- the step is refused (mid-warp, no 3D pass) the press falls through to
|
||||||
|
-- the engine's own handling, which is a no-op, as ever.
|
||||||
|
--
|
||||||
|
-- Installed AFTER FreeMove.install, deliberately: its wrap must sit
|
||||||
|
-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes
|
||||||
|
-- the frame and never calls further in -- would eat the button, and the
|
||||||
|
-- one rung SELECT could not step off of would be 1ST itself.
|
||||||
|
do
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
if not OverworldState.dramaticShapeSelectHook then
|
||||||
|
local inner = OverworldState.handleInput
|
||||||
|
function OverworldState:handleInput(...)
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local input = Game.input
|
||||||
|
if input and input.wasPressed and input:wasPressed("select") then
|
||||||
|
if cycleVoxel(Game) then return end
|
||||||
|
end
|
||||||
|
return inner(self, ...)
|
||||||
|
end
|
||||||
|
OverworldState.dramaticShapeSelectHook = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the konami code, and everything it turns on
|
||||||
|
--
|
||||||
|
-- Installed last of the input seams so its handleInput reasoning sits
|
||||||
|
-- outside FreeMove's and SELECT's. The detector itself does not live on
|
||||||
|
-- handleInput at all -- it reads the fixed step's own press queue, which
|
||||||
|
-- is where keyboard, pad, touch and the VR controllers have all already
|
||||||
|
-- become the same eight buttons. See lib/Horde.lua.
|
||||||
|
Horde.install()
|
||||||
|
|
||||||
|
-- ------- edge-anchored menus stay in the GB frame while a headset is live
|
||||||
|
--
|
||||||
|
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
|
||||||
|
-- menu to the WINDOW's top-right edge. Both VR screens -- the floating
|
||||||
|
-- panel and the Pokedex -- crop the window to the GB frame, so a menu at
|
||||||
|
-- the window's edge is cropped away with the border it docked to. The
|
||||||
|
-- engine's own answer to "a state composes its screen, keep every element
|
||||||
|
-- inside it" is uiAnchorHold, computed per frame from this predicate; a
|
||||||
|
-- live headset is exactly that situation for the WHOLE window, so the
|
||||||
|
-- predicate answers yes for as long as one is. Held menus blit where they
|
||||||
|
-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is
|
||||||
|
-- already flush with the frame's right edge, which is the right edge of
|
||||||
|
-- what the headset sees. Off-headset frames fall through untouched.
|
||||||
|
do
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
if not Game.dramaticShapeAnchorHold then
|
||||||
|
local inner = Game.uiAnchorsHeldInStack
|
||||||
|
function Game.uiAnchorsHeldInStack(stack)
|
||||||
|
if VR.active() then return true end
|
||||||
|
return inner(stack)
|
||||||
|
end
|
||||||
|
Game.dramaticShapeAnchorHold = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The overworld's own pushBattle is the choke point for a wild encounter or
|
-- 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
|
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
|
||||||
-- link battle, a script pushing a BattleState directly -- reaches this
|
-- link battle, a script pushing a BattleState directly -- reaches this
|
||||||
@@ -620,7 +1091,71 @@ mod.events:on("battle.ended", function()
|
|||||||
OverworldBattle.finish()
|
OverworldBattle.finish()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mod.exports.version = "1.2.0"
|
-- ------- 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.4"
|
||||||
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
||||||
-- camera without reaching into this mod's file layout
|
-- camera without reaching into this mod's file layout
|
||||||
mod.exports.lib = V
|
mod.exports.lib = V
|
||||||
|
|||||||
+4
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "DRAMATIC_SHAPE",
|
"id": "DRAMATIC_SHAPE",
|
||||||
"name": "Dramatic Shape Voxel Mod",
|
"name": "Dramatic Shape Voxel Mod",
|
||||||
"version": "1.2.0",
|
"version": "1.5.4",
|
||||||
"api": 2,
|
"api": 2,
|
||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "GRAPHICS",
|
"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,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
@@ -15,5 +15,6 @@
|
|||||||
"engine_internals"
|
"engine_internals"
|
||||||
],
|
],
|
||||||
"affects_link": false,
|
"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,61 @@ return {
|
|||||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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 the engine's TILT and GBC FX; both remain on the OPTIONS menu",
|
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||||
"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",
|
"SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
|
||||||
|
"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",
|
||||||
|
"on the 1ST rung the wall-bump sound is silent -- the free walk slides along whatever it grazes rather than making a discrete blocked step, so the grid game's bonk came out as a rattle; the grid walk keeps its own",
|
||||||
|
"while HORDE MODE runs, the world is held still around it: no wild encounters, no trainers walking up, no talking to anyone, no START menu and no changing the camera rung -- doors and warps keep working, because the crowd follows the player through them",
|
||||||
},
|
},
|
||||||
added = {
|
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",
|
"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",
|
"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",
|
"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",
|
||||||
|
"VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
|
||||||
|
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
|
||||||
|
"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",
|
"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 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",
|
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
|
||||||
|
"SMOOTH TURN options row (OFF / ON, off by default, and only on the menu while VR is ON): turn continuously with the right stick instead of snapping 45 degrees a flick. The snap is the default because a software turn moves the world past a head that did not move, which is the reliable way to make somebody ill in a headset -- but it costs continuity, so the choice is the player's",
|
||||||
|
"HORDE MODE, on the konami code (Up Up Down Down Left Right Left Right B A) standing in the overworld: the sky drops to a starless violet night, the Lavender Town theme comes up, the camera locks into the player's own head (in VR too), a voxel handgun with working iron sights appears in the right hand, and waves of people -- the map's own NPCs among them -- walk out of the dark to kill you. Score per kill, a random Pokemon cry for each one that falls, no pausing; the horde follows you through doors. Health out is a GAME OVER card with the score and PRESS A, which puts the map, the cell, the facing, the camera rung, the hour, the music and every NPC back exactly as they were. Fire on left click, the pad's right trigger or B, a tap on a touch screen, or the right trigger in VR; reload on R, the pad's X, or B on the right controller; aim down the sights on right click or the left trigger",
|
||||||
|
"the horde's gunshot, dry fire, magazine and slide sounds, synthesized on the game's own emulated Game Boy sound hardware (no audio files ship with the mod)",
|
||||||
},
|
},
|
||||||
known = {
|
known = {
|
||||||
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
|
"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",
|
"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 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",
|
"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",
|
"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",
|
"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",
|
||||||
|
"VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path",
|
||||||
|
"VR on and off are both the VR row's job (options menu or manager); no controller button does either",
|
||||||
|
"the Pokedex needs a TRACKED left controller; without one there is no device in hand and the floating panel carries the UI as before",
|
||||||
|
"while the VR row is ON, 3D-BTL is held ON and BACK SPRITES held OFF (the headset's battle staging assumes both), and both rows leave the OPTIONS menu until VR goes off -- their stored values come back with them",
|
||||||
|
"while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, and the engine's edge-anchored menus (the START menu above all) are held inside the frame the same way, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it",
|
||||||
|
"VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours",
|
||||||
|
"HORDE MODE needs the 3D pass, like every rung that has a camera in it: without one the code is refused and nothing happens",
|
||||||
|
"the horde's crowd are the overworld's own sprite billboards, so at close range they are flat cards the size of a person -- they hold two cells off the player for that reason, close enough to swing and far enough to be seen past",
|
||||||
|
"a run is not saved: the score and the best score ride the save slot, but the mode itself has no state to resume, and quitting mid-run simply ends it",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
credits = {
|
credits = {
|
||||||
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
|
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
|
||||||
|
{ who = "The Khronos Group", for_ = "the OpenXR loader shipped unmodified in assets/vr (Apache-2.0; full license text alongside the DLL)" },
|
||||||
},
|
},
|
||||||
compat = { engine = ">=0.1.28 <2.0.0", modApi = 2 },
|
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
|
||||||
|
<metadata>
|
||||||
|
<id>OpenXR.Loader</id>
|
||||||
|
<version>1.0.10.2</version>
|
||||||
|
<authors>Khronos Group</authors>
|
||||||
|
<owners>Khronos Group</owners>
|
||||||
|
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||||
|
<license type="expression">Apache-2.0</license>
|
||||||
|
<licenseUrl>https://licenses.nuget.org/Apache-2.0</licenseUrl>
|
||||||
|
<projectUrl>https://github.com/KhronosGroup/OpenXR-SDK</projectUrl>
|
||||||
|
<description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</description>
|
||||||
|
<tags>native khronos openxr loader headers</tags>
|
||||||
|
<dependencies>
|
||||||
|
<dependency id="OpenXR.Headers" version="1.0.10.2" />
|
||||||
|
</dependencies>
|
||||||
|
</metadata>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
|
||||||
|
<Default Extension="psmdcp" ContentType="application/vnd.openxmlformats-package.core-properties+xml" />
|
||||||
|
<Default Extension="props" ContentType="application/octet" />
|
||||||
|
<Default Extension="targets" ContentType="application/octet" />
|
||||||
|
<Default Extension="dll" ContentType="application/octet" />
|
||||||
|
<Default Extension="lib" ContentType="application/octet" />
|
||||||
|
<Default Extension="nuspec" ContentType="application/octet" />
|
||||||
|
</Types>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Type="http://schemas.microsoft.com/packaging/2010/07/manifest" Target="/OpenXR.Loader.nuspec" Id="R0D169365D22F5E6F" />
|
||||||
|
<Relationship Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp" Id="R10D7CDDCA5670667" />
|
||||||
|
</Relationships>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OpenXRLoaderPackageRoot>$(MSBuildThisFileDirectory)..\..\</OpenXRLoaderPackageRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
|
||||||
|
<Choose>
|
||||||
|
<When Condition="'$(ApplicationType)|$(ApplicationTypeRevision)' == 'Windows Store|10.0'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)_uwp\release</OpenXRLoaderBinaryRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
</When>
|
||||||
|
<Otherwise>
|
||||||
|
<PropertyGroup>
|
||||||
|
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)\release</OpenXRLoaderBinaryRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Otherwise>
|
||||||
|
</Choose>
|
||||||
|
|
||||||
|
<ItemDefinitionGroup>
|
||||||
|
<Link>
|
||||||
|
<AdditionalDependencies>%(AdditionalDependencies);$(OpenXRLoaderBinaryRoot)\lib\openxr_loader.lib</AdditionalDependencies>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
|
||||||
|
<!-- Copy the OpenXR loader DLL to the output directory and include in packaging -->
|
||||||
|
<ItemGroup Condition="'$(OpenXRSkipLoaderCopy)'!='true'">
|
||||||
|
<None Include="$(OpenXRLoaderBinaryRoot)\bin\openxr_loader.dll">
|
||||||
|
<Link>%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<DeploymentContent>true</DeploymentContent>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="EnsurePropsImported" BeforeTargets="PrepareForBuild">
|
||||||
|
<Error Condition="'$(OpenXRLoaderPackageRoot)'==''" Text="OpenXRLoaderPackageRoot property missing. Project is malformed. Try removing and re-adding the NuGet reference." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<coreProperties xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.openxmlformats.org/package/2006/metadata/core-properties">
|
||||||
|
<dc:creator>Khronos Group</dc:creator>
|
||||||
|
<dc:description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</dc:description>
|
||||||
|
<dc:identifier>OpenXR.Loader</dc:identifier>
|
||||||
|
<version>1.0.10.2</version>
|
||||||
|
<keywords>native khronos openxr loader headers</keywords>
|
||||||
|
<lastModifiedBy>NuGet, Version=5.4.0.3, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Microsoft Windows NT 6.2.9200.0;.NET Framework 4.7.2</lastModifiedBy>
|
||||||
|
</coreProperties>
|
||||||
@@ -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
|
||||||
+3114
-30
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,329 @@
|
|||||||
|
-- Scratch driver: HORDE MODE, from the code to the card.
|
||||||
|
--
|
||||||
|
-- Enters the konami code the way a player does -- as Game Boy button
|
||||||
|
-- edges on the fixed step, which is the same path the pad, the touch
|
||||||
|
-- overlay and the VR controllers take -- then photographs the intro
|
||||||
|
-- banner, the darkened sky, the gun at the hip and down the sights, a
|
||||||
|
-- wave mid-chase, the muzzle flash, the reload, a run through a door with
|
||||||
|
-- the crowd behind, and the GAME OVER card. Finishes by pressing A and
|
||||||
|
-- checking that everything the mode changed came back.
|
||||||
|
--
|
||||||
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/horde_shots.lua \
|
||||||
|
-- SHOT_DIR=.scratchpad/horde 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/horde")
|
||||||
|
|
||||||
|
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||||
|
if not (handle and handle.lib) then
|
||||||
|
print("[horde] DRAMATIC_SHAPE mod not loaded")
|
||||||
|
return love.event.quit()
|
||||||
|
end
|
||||||
|
local V = handle.lib
|
||||||
|
local Horde = V.require("Horde")
|
||||||
|
local Mobs = V.require("HordeMobs")
|
||||||
|
local Gun = V.require("HordeGun")
|
||||||
|
local FirstPerson = V.require("FirstPerson")
|
||||||
|
local ChunkMesher = V.require("ChunkMesher")
|
||||||
|
local Voxel = V.require("VoxelState")
|
||||||
|
|
||||||
|
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||||
|
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||||
|
|
||||||
|
local TileRenderer = require("src.render.TileRenderer")
|
||||||
|
TileRenderer.tick = function() end
|
||||||
|
TileRenderer.animFrame = function() return 0 end
|
||||||
|
|
||||||
|
local shots = 0
|
||||||
|
local function shot(name)
|
||||||
|
if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
|
||||||
|
shots = shots + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function settle(frames)
|
||||||
|
for _ = 1, 900 do
|
||||||
|
if ChunkMesher.pending() == 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
U.wait(frames or 30)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the code, entered as edges
|
||||||
|
--
|
||||||
|
-- One button per fixed step, exactly as a device delivers them: the
|
||||||
|
-- driver writes the queue Input:step is about to drain, which is the
|
||||||
|
-- same array a keypress lands in.
|
||||||
|
--
|
||||||
|
-- Each one is RELEASED after. A synthetic inject has no source behind
|
||||||
|
-- it, so Input:step latches it held (see the branch there) and nothing
|
||||||
|
-- ever lets go -- which leaves all four directions down for the rest of
|
||||||
|
-- the run and the player walking into a wall forever. U.tap does the
|
||||||
|
-- same clear for the same reason.
|
||||||
|
local function pressCode()
|
||||||
|
local KONAMI = { "up", "up", "down", "down",
|
||||||
|
"left", "right", "left", "right", "b", "a" }
|
||||||
|
for _, btn in ipairs(KONAMI) do
|
||||||
|
table.insert(game.input.pressQueue, btn)
|
||||||
|
U.wait(2)
|
||||||
|
game.input.state[btn] = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- before
|
||||||
|
U.teleport(game, "PALLET_TOWN", 13, 14, "down")
|
||||||
|
Pipelines.setLevel("voxel", 3) -- the 35-degree diorama
|
||||||
|
Pipelines.setLevel("tiltshift", 0)
|
||||||
|
settle(40)
|
||||||
|
local ow = game.stack:top()
|
||||||
|
local p = ow.player
|
||||||
|
local was = {
|
||||||
|
map = ow.map.id, cellX = p.cellX, cellY = p.cellY, facing = p.facing,
|
||||||
|
level = Pipelines.level("voxel"),
|
||||||
|
npcs = #ow.npcs,
|
||||||
|
}
|
||||||
|
shot("00_before")
|
||||||
|
print(("[horde] before: %s (%d,%d) rung %d, %d npcs")
|
||||||
|
:format(was.map, was.cellX, was.cellY, was.level, was.npcs))
|
||||||
|
|
||||||
|
-- ------- the code lands
|
||||||
|
pressCode()
|
||||||
|
U.wait(4)
|
||||||
|
print("[horde] active: " .. tostring(Horde.active)
|
||||||
|
.. " state: " .. tostring(Horde.state))
|
||||||
|
if not Horde.active then
|
||||||
|
print("[horde] the code did not take -- nothing else here can run")
|
||||||
|
return love.event.quit()
|
||||||
|
end
|
||||||
|
U.wait(20)
|
||||||
|
shot("01_darkness_approaches") -- the banner, over the darkening
|
||||||
|
|
||||||
|
-- and the same announcement at the zoom that used to run it off both
|
||||||
|
-- edges of the screen: it has to wrap (or shrink) rather than overflow
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
pcall(function()
|
||||||
|
game.save.options.zoom = 3
|
||||||
|
Zoom.applyOptions(game.save.options)
|
||||||
|
end)
|
||||||
|
Horde.banner("A DARKNESS APPROACHES", 2.6)
|
||||||
|
U.wait(30)
|
||||||
|
shot("01b_banner_zoomed")
|
||||||
|
pcall(function()
|
||||||
|
game.save.options.zoom = 0
|
||||||
|
Zoom.applyOptions(game.save.options)
|
||||||
|
end)
|
||||||
|
U.wait(10)
|
||||||
|
print(("[horde] rung is now %d (%s)"):format(Pipelines.level("voxel"),
|
||||||
|
Pipelines.levelLabel("voxel")))
|
||||||
|
|
||||||
|
-- the rung must be locked: the key, SELECT and the VR click all call the
|
||||||
|
-- one function this refuses through
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
Game.keypressed(game, "3")
|
||||||
|
print(("[horde] after pressing 3 the rung is %d -- locked: %s")
|
||||||
|
:format(Pipelines.level("voxel"),
|
||||||
|
tostring(Pipelines.level("voxel") == Voxel.FP_LEVEL)))
|
||||||
|
|
||||||
|
-- ------- the first wave
|
||||||
|
for _ = 1, 900 do
|
||||||
|
if Horde.state == "active" and #Horde.session.mobs >= 5 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
FirstPerson.yaw = math.pi -- look north up the street
|
||||||
|
U.wait(30)
|
||||||
|
shot("02_wave_hip")
|
||||||
|
print(("[horde] wave %d, %d mobs standing")
|
||||||
|
:format(Horde.session.wave, #Horde.session.mobs))
|
||||||
|
|
||||||
|
-- ------- START asks the way out
|
||||||
|
--
|
||||||
|
-- The same GB button the pad's START, the keyboard's ESCAPE and the
|
||||||
|
-- touch overlay all press, so this one tap covers every device except
|
||||||
|
-- the VR stick click (which calls the same Horde.askExit).
|
||||||
|
U.tap(game, "start")
|
||||||
|
U.wait(10)
|
||||||
|
local prompt = game.stack:top()
|
||||||
|
print(("[horde] START opened a prompt: %s (still active: %s)")
|
||||||
|
:format(tostring(prompt ~= game.overworld), tostring(Horde.active)))
|
||||||
|
shot("02b_exit_prompt")
|
||||||
|
-- NO, and back to the fight
|
||||||
|
U.tap(game, "b")
|
||||||
|
U.wait(10)
|
||||||
|
print(("[horde] answered NO: back on the overworld %s, active %s")
|
||||||
|
:format(tostring(game.stack:top() == game.overworld),
|
||||||
|
tostring(Horde.active)))
|
||||||
|
|
||||||
|
-- ------- the sights
|
||||||
|
Gun.setAds(true)
|
||||||
|
U.wait(20)
|
||||||
|
shot("03_ads")
|
||||||
|
Gun.setAds(false)
|
||||||
|
U.wait(14)
|
||||||
|
|
||||||
|
-- ------- the shot
|
||||||
|
--
|
||||||
|
-- Aimed deliberately at the nearest mob rather than fired into the
|
||||||
|
-- street: what is being checked is that the ray finds a body, that the
|
||||||
|
-- body dies, and that the kill is worth something.
|
||||||
|
local function aimAtNearest()
|
||||||
|
local best, bestD
|
||||||
|
for _, e in ipairs(Horde.session.mobs) do
|
||||||
|
local dx = (e.npc.px + 8) - (p.px + 8)
|
||||||
|
local dz = (e.npc.py + 8) - (p.py + 8)
|
||||||
|
local d = dx * dx + dz * dz
|
||||||
|
if not bestD or d < bestD then best, bestD = e, d end
|
||||||
|
end
|
||||||
|
if not best then return nil end
|
||||||
|
local dx = (best.npc.px + 8) - (p.px + 8)
|
||||||
|
local dz = (best.npc.py + 8) - (p.py + 8)
|
||||||
|
FirstPerson.yaw = math.atan2(dx, dz)
|
||||||
|
FirstPerson.pitch = 0
|
||||||
|
return best, math.sqrt(bestD)
|
||||||
|
end
|
||||||
|
|
||||||
|
local scoreWas, killsWas = Horde.session.score, Horde.session.kills
|
||||||
|
local target, range = aimAtNearest()
|
||||||
|
print(("[horde] aiming at a mob %s px away, yaw %.2f")
|
||||||
|
:format(range and ("%.0f"):format(range) or "?", FirstPerson.yaw))
|
||||||
|
U.wait(6)
|
||||||
|
Gun.fire()
|
||||||
|
U.wait(1)
|
||||||
|
shot("04_muzzle_flash")
|
||||||
|
-- a mob takes more than one round as the waves stack; keep firing at
|
||||||
|
-- whatever is nearest until something dies or the magazine is out
|
||||||
|
for _ = 1, 7 do
|
||||||
|
if Horde.session.kills > killsWas then break end
|
||||||
|
U.wait(16)
|
||||||
|
aimAtNearest()
|
||||||
|
Gun.fire()
|
||||||
|
end
|
||||||
|
U.wait(20)
|
||||||
|
print(("[horde] fired: ammo %d, score %d -> %d, kills %d -> %d")
|
||||||
|
:format(select(1, Gun.ammo()), scoreWas, Horde.session.score,
|
||||||
|
killsWas, Horde.session.kills))
|
||||||
|
|
||||||
|
-- ------- the reload, caught mid-dip
|
||||||
|
for _ = 1, 8 do
|
||||||
|
Gun.fire()
|
||||||
|
U.wait(12)
|
||||||
|
end
|
||||||
|
Gun.reload()
|
||||||
|
U.wait(45)
|
||||||
|
shot("05_reloading")
|
||||||
|
print("[horde] reloading: " .. tostring(select(3, Gun.ammo())))
|
||||||
|
for _ = 1, 200 do
|
||||||
|
if not select(3, Gun.ammo()) then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
print(("[horde] reloaded to %d"):format(select(1, Gun.ammo())))
|
||||||
|
|
||||||
|
-- ------- through a door, with the crowd behind
|
||||||
|
local before = #Horde.session.mobs
|
||||||
|
U.teleport(game, "REDS_HOUSE_1F", 3, 6, "down")
|
||||||
|
settle(60)
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if #Horde.session.mobs > 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
U.wait(60)
|
||||||
|
shot("06_indoors_followed")
|
||||||
|
print(("[horde] indoors: %d mobs followed (was %d outside)")
|
||||||
|
:format(#Horde.session.mobs, before))
|
||||||
|
|
||||||
|
-- ------- the end
|
||||||
|
--
|
||||||
|
-- Drained rather than played out, so the driver finishes in seconds and
|
||||||
|
-- the card is photographed from the same path a real death takes.
|
||||||
|
Horde.session.hp = 1
|
||||||
|
Horde.session.hurtCooldown = 0
|
||||||
|
Horde.damage(50)
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if Horde.state == "gameover" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
U.wait(40)
|
||||||
|
shot("07_game_over")
|
||||||
|
print(("[horde] game over at score %d, wave %d, %d kills")
|
||||||
|
:format(Horde.session and Horde.session.score or -1,
|
||||||
|
Horde.session and Horde.session.wave or -1,
|
||||||
|
Horde.session and Horde.session.kills or -1))
|
||||||
|
|
||||||
|
-- ------- and back
|
||||||
|
U.tap(game, "a")
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if not Horde.active and not game.stack:top().transitioning then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
settle(60)
|
||||||
|
local now = game.stack:top()
|
||||||
|
local np = now.player
|
||||||
|
print(("[horde] restored: %s (%d,%d) facing %s, rung %d, %d npcs")
|
||||||
|
:format(now.map.id, np.cellX, np.cellY, np.facing,
|
||||||
|
Pipelines.level("voxel"), #now.npcs))
|
||||||
|
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
|
||||||
|
:format(tostring(now.map.id == was.map),
|
||||||
|
tostring(np.cellX == was.cellX and np.cellY == was.cellY),
|
||||||
|
tostring(np.facing == was.facing),
|
||||||
|
tostring(Pipelines.level("voxel") == was.level),
|
||||||
|
tostring(#now.npcs == was.npcs)))
|
||||||
|
-- nothing of the mode may be left on any map
|
||||||
|
local left = 0
|
||||||
|
for _, def in pairs(game.data.maps) do
|
||||||
|
for _, obj in ipairs(def.objects or {}) do
|
||||||
|
if obj.hordeMob then left = left + 1 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print(("[horde] horde objects left behind: %d"):format(left))
|
||||||
|
shot("08_after")
|
||||||
|
|
||||||
|
-- ------- and out the other door
|
||||||
|
--
|
||||||
|
-- The same restore, reached the other way: enter the code again and
|
||||||
|
-- leave through START -> YES rather than by dying. This is the path a
|
||||||
|
-- player who just wants their game back actually takes.
|
||||||
|
pressCode()
|
||||||
|
U.wait(6)
|
||||||
|
if not Horde.active then
|
||||||
|
print("[horde] second activation refused -- the exit path is untested")
|
||||||
|
print(("[horde] %d shots into %s"):format(shots, ROOT))
|
||||||
|
return love.event.quit()
|
||||||
|
end
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if Horde.state == "active" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
U.tap(game, "start")
|
||||||
|
U.wait(10)
|
||||||
|
U.tap(game, "up") -- NO -> YES
|
||||||
|
U.wait(6)
|
||||||
|
shot("09_exit_yes")
|
||||||
|
U.tap(game, "a")
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if not Horde.active and not game.stack:top().transitioning then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
settle(60)
|
||||||
|
local out = game.stack:top()
|
||||||
|
local op = out.player
|
||||||
|
print(("[horde] exited via START/YES: active %s, %s (%d,%d) facing %s, rung %d")
|
||||||
|
:format(tostring(Horde.active), out.map.id, op.cellX, op.cellY,
|
||||||
|
op.facing, Pipelines.level("voxel")))
|
||||||
|
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
|
||||||
|
:format(tostring(out.map.id == was.map),
|
||||||
|
tostring(op.cellX == was.cellX and op.cellY == was.cellY),
|
||||||
|
tostring(op.facing == was.facing),
|
||||||
|
tostring(Pipelines.level("voxel") == was.level),
|
||||||
|
tostring(#out.npcs == was.npcs)))
|
||||||
|
local left2 = 0
|
||||||
|
for _, def in pairs(game.data.maps) do
|
||||||
|
for _, obj in ipairs(def.objects or {}) do
|
||||||
|
if obj.hordeMob then left2 = left2 + 1 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print(("[horde] horde objects left behind: %d"):format(left2))
|
||||||
|
|
||||||
|
print(("[horde] %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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user